commit ed94dd22ddca71d63a4b7faac46dfa97da6cdace Author: Iliyan Angelov Date: Wed Nov 26 22:32:20 2025 +0200 update diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82b4ac6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +/media +/staticfiles +/static + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# OSINT +*.pem +*.key + +# Logs +logs/ +*.log + +# Database +*.db + diff --git a/FRAUD_SCAM_PLATFORM_ROADMAP.md b/FRAUD_SCAM_PLATFORM_ROADMAP.md new file mode 100644 index 0000000..9079846 --- /dev/null +++ b/FRAUD_SCAM_PLATFORM_ROADMAP.md @@ -0,0 +1,727 @@ +# Fraud & Scam Reporting Platform - Development Roadmap + +## Project Overview + +A secure, GDPR-compliant platform for reporting and tracking fraud and scams in the Bulgarian internet space. The platform will use OSINT (Open Source Intelligence) techniques to trace and verify reported scams, providing a public database to help citizens stay informed and protected. + +--- + +## 1. Legal Compliance & Requirements + +### 1.1 GDPR Compliance +- **Data Minimization**: Collect only necessary personal data +- **Consent Management**: Explicit consent for data processing +- **Right to Access**: Users can request their data +- **Right to Erasure**: Users can request data deletion +- **Data Portability**: Export user data in machine-readable format +- **Privacy by Design**: Security measures built into the system +- **Data Protection Officer (DPO)**: Appoint or designate DPO +- **Data Breach Notification**: 72-hour notification to authorities +- **Privacy Policy**: Comprehensive, clear, and accessible +- **Cookie Consent**: GDPR-compliant cookie management + +### 1.2 Bulgarian Law Compliance +- **Personal Data Protection Act (PDPA)**: Align with Bulgarian implementation of GDPR +- **Electronic Commerce Act**: Compliance for online services +- **Consumer Protection Act**: Protect users' rights +- **Cybercrime Act**: Legal framework for reporting cybercrimes +- **Defamation Laws**: Ensure reports are factual and verified +- **Data Retention**: Comply with Bulgarian data retention requirements +- **Terms of Service**: Legally binding terms in Bulgarian and English + +### 1.3 Legal Documentation Required +- Privacy Policy (BG/EN) +- Terms of Service (BG/EN) +- Cookie Policy +- Data Processing Agreement templates +- User Consent Forms +- Data Subject Rights Request Forms + +--- + +## 2. Security Architecture + +### 2.1 Authentication & Authorization +- **Multi-factor Authentication (MFA)**: Required for admins and moderators +- **Strong Password Policy**: Minimum 12 characters, complexity requirements +- **Password Hashing**: Use bcrypt or Argon2 +- **Session Management**: Secure, HTTP-only, SameSite cookies +- **JWT Tokens**: For API authentication (if needed) +- **Rate Limiting**: Prevent brute force attacks +- **Account Lockout**: After failed login attempts +- **OAuth 2.0**: Optional social login (with privacy considerations) + +### 2.2 Data Security +- **Encryption at Rest**: Encrypt sensitive database fields +- **Encryption in Transit**: TLS 1.3 for all connections +- **Database Encryption**: PostgreSQL encryption +- **Backup Encryption**: Encrypted backups +- **PII Masking**: Mask sensitive data in logs +- **Secure File Uploads**: Validate, scan, and store securely +- **SQL Injection Prevention**: Use Django ORM, parameterized queries +- **XSS Prevention**: Content Security Policy, input sanitization + +### 2.3 Infrastructure Security +- **HTTPS Only**: Force HTTPS, HSTS headers +- **Security Headers**: + - Content-Security-Policy + - X-Frame-Options + - X-Content-Type-Options + - Referrer-Policy + - Permissions-Policy +- **DDoS Protection**: CloudFlare or similar +- **WAF (Web Application Firewall)**: Protect against common attacks +- **Regular Security Audits**: Penetration testing +- **Vulnerability Scanning**: Automated security scans +- **Intrusion Detection System (IDS)**: Monitor for suspicious activity +- **Firewall Rules**: Restrict database access + +### 2.4 Code Security +- **Dependency Scanning**: Check for vulnerable packages +- **Secret Management**: Use environment variables, secrets manager +- **Input Validation**: Validate all user inputs +- **CSRF Protection**: Django CSRF tokens +- **Security Logging**: Log security events +- **Error Handling**: Don't expose sensitive information in errors + +--- + +## 3. Technical Architecture + +### 3.1 Technology Stack +- **Backend**: Django 4.2+ (Python 3.11+) +- **Database**: PostgreSQL 15+ +- **Frontend**: HTML5, CSS3, JavaScript (Vanilla or minimal framework) +- **Web Server**: Nginx +- **WSGI Server**: Gunicorn or uWSGI +- **Caching**: Redis +- **Task Queue**: Celery (for OSINT tasks) +- **OSINT Tools**: Custom integrations with public APIs and tools + +### 3.2 Project Structure +``` +fraud_scam_platform/ +├── manage.py +├── requirements.txt +├── .env.example +├── docker-compose.yml (optional) +├── fraud_platform/ +│ ├── settings/ +│ │ ├── __init__.py +│ │ ├── base.py +│ │ ├── development.py +│ │ ├── production.py +│ │ └── security.py +│ ├── urls.py +│ ├── wsgi.py +│ └── asgi.py +├── apps/ +│ ├── accounts/ # User management +│ ├── reports/ # Scam/fraud reports +│ ├── osint/ # OSINT integration +│ ├── moderation/ # Moderation system +│ ├── analytics/ # Analytics and statistics +│ └── legal/ # Legal compliance tools +├── templates/ +├── static/ +├── media/ +└── tests/ +``` + +--- + +## 4. Database Design (PostgreSQL) + +### 4.1 Core Tables + +#### Users & Authentication +- **users_user**: Extended user model + - id, email, username, password_hash + - role (normal, moderator, admin) + - is_verified, is_active + - created_at, updated_at + - last_login, mfa_enabled + +- **users_userprofile**: Additional user information + - user_id (FK) + - first_name, last_name + - phone (encrypted) + - date_of_birth (if required) + - consent_given, consent_date + - preferred_language + +- **users_activitylog**: User activity tracking + - user_id, action, ip_address + - timestamp, user_agent + +#### Reports +- **reports_scamreport**: Main report table + - id, reporter_id (FK) + - title, description + - scam_type, category + - reported_url, reported_phone, reported_email + - evidence_files (JSON) + - status (pending, under_review, verified, rejected, archived) + - verification_score + - created_at, updated_at + - is_public, is_anonymous + +- **reports_scamverification**: OSINT verification data + - report_id (FK) + - verification_method + - verification_data (JSON) + - confidence_score + - verified_by (FK to user) + - verified_at + +- **reports_scamtag**: Tags for categorization + - id, name, slug, description + +- **reports_scamreport_tags**: Many-to-many relationship + +#### Moderation +- **moderation_moderationaction**: Moderation actions + - id, report_id (FK) + - moderator_id (FK) + - action_type (approve, reject, edit, delete) + - reason, notes + - created_at + +- **moderation_moderationqueue**: Queue for pending reviews + - report_id (FK) + - priority, assigned_to (FK) + - created_at + +#### OSINT Data +- **osint_osintresult**: OSINT investigation results + - id, report_id (FK) + - source, data_type + - raw_data (JSON) + - processed_data (JSON) + - confidence_level + - collected_at + +- **osint_osinttask**: Background tasks for OSINT + - id, report_id (FK) + - task_type, status + - parameters (JSON) + - result (JSON) + - created_at, completed_at + +#### Legal & Compliance +- **legal_consentrecord**: User consent tracking + - user_id (FK) + - consent_type, consent_given + - ip_address, user_agent + - timestamp + +- **legal_datarequest**: GDPR data requests + - id, user_id (FK) + - request_type (access, deletion, portability) + - status, requested_at + - completed_at, response_data + +#### Security +- **security_securityevent**: Security event logging + - id, event_type + - user_id (nullable), ip_address + - details (JSON) + - severity, timestamp + +- **security_failedlogin**: Failed login attempts + - id, email/username + - ip_address, user_agent + - timestamp + +### 4.2 Database Security +- **Row-Level Security (RLS)**: Implement where applicable +- **Encrypted Fields**: Use pgcrypto for sensitive data +- **Backup Strategy**: Daily encrypted backups +- **Access Control**: Limited database user permissions +- **Audit Logging**: Track all data modifications + +--- + +## 5. User Roles & Permissions + +### 5.1 Normal Users +**Permissions:** +- Create scam/fraud reports +- View public reports +- Edit own reports (before moderation) +- Delete own reports (before moderation) +- Comment on reports (optional) +- Request data access/deletion +- Report inappropriate content + +**Restrictions:** +- Cannot approve/reject reports +- Cannot access admin panel +- Cannot view private reports +- Limited API rate limits + +### 5.2 Moderators +**Permissions:** +- All normal user permissions +- Review and moderate reports +- Approve/reject reports +- Edit any report +- Add verification data +- Manage tags +- View moderation queue +- Access moderation dashboard +- View user activity logs (limited) + +**Restrictions:** +- Cannot delete users +- Cannot change user roles +- Cannot access system settings +- Cannot view all security logs + +### 5.3 Administrators +**Permissions:** +- All moderator permissions +- Full admin panel access +- User management (create, edit, delete, change roles) +- System configuration +- Security logs access +- Database access (read-only recommended) +- OSINT configuration +- Analytics and reporting +- Legal compliance tools + +**Security Requirements:** +- Mandatory MFA +- Regular security audits +- Activity logging +- IP whitelisting (optional) + +--- + +## 6. OSINT Integration + +### 6.1 OSINT Sources & Tools +- **Domain/URL Analysis**: + - WHOIS lookups + - DNS records + - SSL certificate information + - Wayback Machine (archive.org) + - URL reputation services + +- **Email Analysis**: + - Email validation services + - Breach databases (Have I Been Pwned) + - Email reputation checks + +- **Phone Number Analysis**: + - Phone number validation + - Carrier lookup + - Number reputation databases + +- **Social Media**: + - Public profile checks + - Account verification status + - Activity patterns + +- **Bulgarian-Specific Sources**: + - Bulgarian business registry (APIS) + - Bulgarian National Revenue Agency (public data) + - Bulgarian Consumer Protection Commission + - Bulgarian Financial Supervision Commission + - Local news and media archives + +### 6.2 OSINT Workflow +1. **Report Submission**: User submits scam report +2. **Initial Processing**: System extracts entities (URLs, emails, phones) +3. **OSINT Task Creation**: Create background tasks for each entity +4. **Data Collection**: Run OSINT tools and collect data +5. **Data Analysis**: Process and analyze collected data +6. **Verification Scoring**: Calculate confidence score +7. **Moderator Review**: Moderator reviews OSINT results +8. **Report Status Update**: Update report based on findings + +### 6.3 OSINT Implementation +- **Celery Tasks**: Background processing +- **API Integrations**: REST APIs for OSINT services +- **Rate Limiting**: Respect API rate limits +- **Caching**: Cache OSINT results to avoid duplicate queries +- **Error Handling**: Graceful handling of API failures +- **Data Storage**: Store raw and processed OSINT data + +--- + +## 7. Features & Functionality + +### 7.1 Public Features +- **Report Submission Form**: + - Scam type selection + - Description field + - URL/Email/Phone input + - File upload (evidence) + - Anonymous reporting option + - Consent checkboxes + +- **Public Database**: + - Searchable list of verified scams + - Filter by type, date, category + - Scam details page + - Verification status indicator + - OSINT evidence display (sanitized) + +- **Statistics Dashboard**: + - Total reports + - Scam types breakdown + - Trends over time + - Geographic distribution (if applicable) + +### 7.2 User Features +- **User Dashboard**: + - My reports + - Report status tracking + - Edit/delete own reports + - Data request management + +- **Profile Management**: + - Edit profile + - Change password + - Enable/disable MFA + - Privacy settings + - Consent management + +### 7.3 Moderation Features +- **Moderation Dashboard**: + - Pending reports queue + - Priority sorting + - Bulk actions + - Statistics + +- **Report Review**: + - View full report details + - Review OSINT results + - Add verification notes + - Approve/reject with reason + - Edit report content + +### 7.4 Admin Features +- **User Management**: + - User list and search + - Edit user details + - Change user roles + - Suspend/activate users + - View user activity + +- **System Configuration**: + - OSINT settings + - Email templates + - Security settings + - Legal document management + +- **Analytics**: + - Platform statistics + - User activity reports + - Security event reports + - Compliance reports + +--- + +## 8. Development Phases + +### Phase 1: Foundation (Weeks 1-4) +- [ ] Project setup and configuration +- [ ] Database design and migration +- [ ] User authentication system +- [ ] Basic user roles and permissions +- [ ] Security framework implementation +- [ ] Legal documentation templates + +### Phase 2: Core Features (Weeks 5-8) +- [ ] Report submission system +- [ ] Report listing and search +- [ ] Basic moderation system +- [ ] User dashboard +- [ ] File upload and management +- [ ] Email notifications + +### Phase 3: OSINT Integration (Weeks 9-12) +- [ ] OSINT task system (Celery) +- [ ] Domain/URL analysis integration +- [ ] Email analysis integration +- [ ] Phone number analysis +- [ ] Bulgarian-specific sources integration +- [ ] OSINT result processing and scoring + +### Phase 4: Moderation & Admin (Weeks 13-16) +- [ ] Advanced moderation dashboard +- [ ] Moderation queue system +- [ ] Admin panel development +- [ ] User management interface +- [ ] Analytics dashboard +- [ ] Reporting system + +### Phase 5: Security & Compliance (Weeks 17-20) +- [ ] GDPR compliance tools +- [ ] Data request handling +- [ ] Consent management +- [ ] Security audit implementation +- [ ] Penetration testing +- [ ] Security hardening + +### Phase 6: Testing & Optimization (Weeks 21-24) +- [ ] Unit testing +- [ ] Integration testing +- [ ] Security testing +- [ ] Performance optimization +- [ ] Load testing +- [ ] Bug fixes + +### Phase 7: Deployment & Launch (Weeks 25-26) +- [ ] Production environment setup +- [ ] SSL certificates +- [ ] Database migration +- [ ] Monitoring setup +- [ ] Backup system +- [ ] Launch and monitoring + +--- + +## 9. Security Checklist + +### Authentication & Access +- [ ] Strong password requirements enforced +- [ ] MFA implemented for admins/moderators +- [ ] Session timeout configured +- [ ] Account lockout after failed attempts +- [ ] Rate limiting on login endpoints +- [ ] Secure password reset flow + +### Data Protection +- [ ] All sensitive data encrypted at rest +- [ ] TLS 1.3 enforced +- [ ] Database encryption enabled +- [ ] Backup encryption enabled +- [ ] PII masking in logs +- [ ] Secure file storage + +### Application Security +- [ ] CSRF protection enabled +- [ ] XSS prevention implemented +- [ ] SQL injection prevention +- [ ] Input validation on all forms +- [ ] File upload validation +- [ ] Security headers configured +- [ ] Content Security Policy + +### Infrastructure +- [ ] HTTPS only +- [ ] Firewall rules configured +- [ ] Database access restricted +- [ ] Regular security updates +- [ ] Intrusion detection +- [ ] DDoS protection + +### Monitoring & Logging +- [ ] Security event logging +- [ ] Failed login tracking +- [ ] User activity logging +- [ ] Error logging (sanitized) +- [ ] Monitoring alerts +- [ ] Regular security audits + +--- + +## 10. GDPR Compliance Checklist + +### Data Collection +- [ ] Privacy policy created and accessible +- [ ] Consent forms implemented +- [ ] Data minimization practiced +- [ ] Purpose limitation clear +- [ ] Legal basis documented + +### Data Processing +- [ ] Data processing agreements +- [ ] Third-party processor agreements +- [ ] Data retention policies +- [ ] Data deletion procedures + +### User Rights +- [ ] Right to access implementation +- [ ] Right to rectification +- [ ] Right to erasure +- [ ] Right to data portability +- [ ] Right to object +- [ ] Right to restrict processing + +### Security & Breaches +- [ ] Data breach notification procedure +- [ ] Security measures documented +- [ ] Regular security assessments +- [ ] Incident response plan + +### Documentation +- [ ] Data processing register +- [ ] Privacy impact assessments +- [ ] DPO contact information +- [ ] Regular compliance reviews + +--- + +## 11. Bulgarian Law Compliance Checklist + +- [ ] Personal Data Protection Act compliance +- [ ] Electronic Commerce Act compliance +- [ ] Consumer Protection Act alignment +- [ ] Terms of Service in Bulgarian +- [ ] Privacy Policy in Bulgarian +- [ ] Bulgarian business registration (if applicable) +- [ ] Tax compliance (if applicable) +- [ ] Local hosting requirements (if applicable) + +--- + +## 12. Deployment Considerations + +### 12.1 Hosting +- **Recommended**: Bulgarian or EU-based hosting (GDPR) +- **Options**: AWS EU, DigitalOcean EU, Bulgarian hosting providers +- **Requirements**: + - PostgreSQL support + - SSL certificates + - Backup capabilities + - Monitoring tools + +### 12.2 Environment Configuration +- **Development**: Local development environment +- **Staging**: Pre-production testing +- **Production**: Live environment +- **Environment Variables**: Secure secret management + +### 12.3 Monitoring & Maintenance +- **Application Monitoring**: Error tracking (Sentry) +- **Server Monitoring**: Uptime monitoring +- **Database Monitoring**: Query performance +- **Security Monitoring**: Intrusion detection +- **Backup Monitoring**: Verify backups regularly + +### 12.4 Backup Strategy +- **Database Backups**: Daily automated backups +- **File Backups**: Daily media file backups +- **Backup Retention**: 30 days minimum +- **Backup Testing**: Monthly restore tests +- **Offsite Backups**: Store backups separately + +--- + +## 13. Post-Launch Considerations + +### 13.1 Maintenance +- Regular security updates +- Dependency updates +- Database optimization +- Performance monitoring +- User feedback collection + +### 13.2 Continuous Improvement +- Feature enhancements based on feedback +- OSINT source expansion +- Security improvements +- Performance optimization +- Legal compliance updates + +### 13.3 Community Engagement +- User education about scams +- Regular blog posts/articles +- Social media presence +- Partnership with authorities +- Public awareness campaigns + +--- + +## 14. Risk Management + +### 14.1 Technical Risks +- **Data Breach**: Mitigation through security measures +- **DDoS Attacks**: DDoS protection service +- **System Downtime**: Redundancy and monitoring +- **Data Loss**: Regular backups + +### 14.2 Legal Risks +- **GDPR Violations**: Regular compliance audits +- **Defamation Claims**: Moderation and verification +- **Data Subject Complaints**: Clear procedures +- **Regulatory Changes**: Regular legal review + +### 14.3 Operational Risks +- **Moderator Availability**: Multiple moderators +- **OSINT Service Failures**: Multiple sources, caching +- **User Abuse**: Reporting and moderation tools +- **Scalability**: Plan for growth + +--- + +## 15. Resources & References + +### 15.1 Django Resources +- Django Security Best Practices +- Django GDPR Compliance Guide +- Django Authentication System + +### 15.2 Security Resources +- OWASP Top 10 +- GDPR Official Guidelines +- Bulgarian Personal Data Protection Commission + +### 15.3 OSINT Resources +- OSINT Framework +- Bulgarian Public Registries +- Open Source Intelligence Tools + +--- + +## 16. Success Metrics + +### 16.1 Platform Metrics +- Number of reports submitted +- Number of verified scams +- User registration rate +- Report verification time +- Platform uptime + +### 16.2 Security Metrics +- Number of security incidents +- Failed login attempts +- Security audit results +- Response time to incidents + +### 16.3 Compliance Metrics +- GDPR request response time +- Data breach incidents (target: 0) +- Compliance audit results +- User consent rate + +--- + +## Conclusion + +This roadmap provides a comprehensive guide for developing a secure, GDPR-compliant fraud and scam reporting platform. The project should be developed incrementally, with security and legal compliance as top priorities throughout all phases. + +**Key Principles:** +1. Security first +2. Legal compliance from day one +3. User privacy and data protection +4. Transparency and accountability +5. Continuous improvement + +**Next Steps:** +1. Review and approve this roadmap +2. Set up development environment +3. Begin Phase 1 implementation +4. Consult with legal experts for compliance +5. Establish security review process + +--- + +*Document Version: 1.0* +*Last Updated: [Date]* +*Maintained by: Development Team* + diff --git a/OSINT_SYSTEM_README.md b/OSINT_SYSTEM_README.md new file mode 100644 index 0000000..7d2a8c8 --- /dev/null +++ b/OSINT_SYSTEM_README.md @@ -0,0 +1,266 @@ +# Enterprise OSINT System Documentation + +## Overview + +The Enterprise OSINT (Open Source Intelligence) system automatically crawls seed websites, searches for scam-related keywords, and generates reports for moderator review. Approved reports are automatically published to the platform. + +## Features + +### 1. Seed Website Management +- **Admin Interface**: Manage seed websites to crawl +- **Configuration**: Set crawl depth, interval, allowed domains, user agent +- **Priority Levels**: High, Medium, Low +- **Statistics**: Track pages crawled and matches found + +### 2. Keyword Management +- **Multiple Types**: Exact match, regex, phrase, domain, email, phone patterns +- **Confidence Scoring**: Each keyword has a confidence score (0-100) +- **Auto-approval**: Keywords can be set to auto-approve high-confidence matches +- **Case Sensitivity**: Configurable per keyword + +### 3. Automated Crawling +- **Web Scraping**: Crawls seed websites using BeautifulSoup +- **Content Analysis**: Extracts and analyzes page content +- **Keyword Matching**: Searches for configured keywords +- **Deduplication**: Uses content hashing to avoid duplicates +- **Rate Limiting**: Configurable delays between requests + +### 4. Auto-Report Generation +- **Automatic Detection**: Creates reports when keywords match +- **Confidence Scoring**: Calculates confidence based on matches +- **Moderator Review**: Reports sent for approval +- **Auto-approval**: High-confidence reports with auto-approve keywords are automatically published + +### 5. Moderation Interface +- **Review Queue**: Moderators can review pending auto-generated reports +- **Approve/Reject**: One-click approval or rejection with notes +- **Statistics Dashboard**: View counts by status +- **Detailed View**: See full crawled content and matched keywords + +## Setup Instructions + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +New dependencies added: +- `beautifulsoup4>=4.12.2` - Web scraping +- `lxml>=4.9.3` - HTML parsing +- `urllib3>=2.0.7` - HTTP client + +### 2. Run Migrations + +```bash +python manage.py makemigrations osint +python manage.py makemigrations reports # For is_auto_discovered field +python manage.py migrate +``` + +### 3. Configure Seed Websites + +1. Go to Django Admin → OSINT → Seed Websites +2. Click "Add Seed Website" +3. Fill in: + - **Name**: Friendly name + - **URL**: Base URL to crawl + - **Crawl Depth**: How many levels deep to crawl (0 = only this page) + - **Crawl Interval**: Hours between crawls + - **Priority**: High/Medium/Low + - **Allowed Domains**: List of domains to crawl (empty = same domain only) + - **User Agent**: Custom user agent string + +### 4. Configure Keywords + +1. Go to Django Admin → OSINT → OSINT Keywords +2. Click "Add OSINT Keyword" +3. Fill in: + - **Name**: Friendly name + - **Keyword**: The pattern to search for + - **Keyword Type**: + - `exact` - Exact string match + - `regex` - Regular expression + - `phrase` - Phrase with word boundaries + - `domain` - Domain pattern + - `email` - Email pattern + - `phone` - Phone pattern + - **Confidence Score**: Default confidence (0-100) + - **Auto Approve**: Auto-approve if confidence >= 80 + +### 5. Run Crawling + +#### Manual Crawling + +```bash +# Crawl all due seed websites +python manage.py crawl_osint + +# Crawl all active seed websites +python manage.py crawl_osint --all + +# Crawl specific seed website +python manage.py crawl_osint --seed-id 1 + +# Force crawl (ignore crawl interval) +python manage.py crawl_osint --all --force + +# Limit pages per seed +python manage.py crawl_osint --max-pages 100 + +# Set delay between requests +python manage.py crawl_osint --delay 2.0 +``` + +#### Scheduled Crawling (Celery) + +Add to your Celery beat schedule: + +```python +# In your Celery configuration (celery.py or settings) +from celery.schedules import crontab + +app.conf.beat_schedule = { + 'crawl-osint-hourly': { + 'task': 'osint.tasks.crawl_osint_seeds', + 'schedule': crontab(minute=0), # Every hour + }, + 'auto-approve-reports': { + 'task': 'osint.tasks.auto_approve_high_confidence_reports', + 'schedule': crontab(minute='*/15'), # Every 15 minutes + }, +} +``` + +## Workflow + +### 1. Crawling Process + +``` +Seed Website → Crawl Pages → Extract Content → Match Keywords → Calculate Confidence +``` + +1. System crawls seed website starting from base URL +2. For each page: + - Fetches HTML content + - Extracts text content (removes scripts/styles) + - Calculates content hash for deduplication + - Matches against all active keywords + - Calculates confidence score +3. If confidence >= 30, creates `CrawledContent` record +4. If confidence >= 30, creates `AutoGeneratedReport` with status 'pending' + +### 2. Confidence Calculation + +``` +Base Score = Average of matched keyword confidence scores +Match Boost = min(match_count * 2, 30) +Keyword Boost = min(unique_keywords * 5, 20) +Total = min(base_score + match_boost + keyword_boost, 100) +``` + +### 3. Auto-Approval + +Reports are auto-approved if: +- Confidence score >= 80 +- At least one matched keyword has `auto_approve=True` + +Auto-approved reports are immediately published to the platform. + +### 4. Moderator Review + +1. Moderator views pending reports at `/osint/auto-reports/` +2. Can filter by status (pending, approved, published, rejected) +3. Views details including: + - Matched keywords + - Crawled content + - Source URL + - Confidence score +4. Approves or rejects with optional notes +5. Approved reports are published as `ScamReport` with `is_auto_discovered=True` + +## URL Routes + +- `/osint/auto-reports/` - List auto-generated reports (moderators only) +- `/osint/auto-reports//` - View report details +- `/osint/auto-reports//approve/` - Approve report +- `/osint/auto-reports//reject/` - Reject report + +## Models + +### SeedWebsite +- Manages websites to crawl +- Tracks crawling statistics +- Configures crawl behavior + +### OSINTKeyword +- Defines patterns to search for +- Sets confidence scores +- Enables auto-approval + +### CrawledContent +- Stores crawled page content +- Links matched keywords +- Tracks confidence scores + +### AutoGeneratedReport +- Generated from crawled content +- Links to ScamReport when approved +- Tracks review status + +## Best Practices + +1. **Start Small**: Begin with 1-2 seed websites and a few keywords +2. **Monitor Performance**: Check crawl statistics regularly +3. **Tune Keywords**: Adjust confidence scores based on false positives +4. **Respect Rate Limits**: Use appropriate delays to avoid being blocked +5. **Review Regularly**: Check pending reports daily +6. **Update Keywords**: Add new scam patterns as they emerge +7. **Test Regex**: Validate regex patterns before activating + +## Troubleshooting + +### Crawling Fails +- Check network connectivity +- Verify seed website URLs are accessible +- Check user agent and rate limiting +- Review error messages in admin + +### Too Many False Positives +- Increase confidence score thresholds +- Refine keyword patterns +- Add negative keywords (future feature) + +### Too Few Matches +- Lower confidence thresholds +- Add more keywords +- Check if seed websites are being crawled +- Verify keyword patterns match content + +### Performance Issues +- Reduce crawl depth +- Limit max pages per crawl +- Increase delay between requests +- Use priority levels to focus on important sites + +## Security Considerations + +1. **User Agent**: Use identifiable user agent for transparency +2. **Rate Limiting**: Respect website terms of service +3. **Content Storage**: Large HTML content stored in database +4. **API Keys**: Store OSINT service API keys securely (encrypted) +5. **Access Control**: Only moderators can review reports + +## Future Enhancements + +- [ ] Negative keywords to reduce false positives +- [ ] Machine learning for better pattern recognition +- [ ] Image analysis for scam detection +- [ ] Social media monitoring +- [ ] Email/phone validation services +- [ ] Automated report categorization +- [ ] Export/import keyword sets +- [ ] Crawl scheduling per seed website +- [ ] Content change detection +- [ ] Multi-language support + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f0eb4e --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# Fraud & Scam Reporting Platform + +A secure, GDPR-compliant Django platform for reporting and tracking fraud and scams in the Bulgarian internet space. + +## Features + +- **User Management**: Role-based access (Normal Users, Moderators, Administrators) +- **Report System**: Submit and track scam/fraud reports +- **OSINT Integration**: Automated intelligence gathering for verification +- **Moderation System**: Queue-based moderation workflow +- **Analytics Dashboard**: Statistics and insights +- **GDPR Compliance**: Data request handling and consent management +- **Security**: Multi-factor authentication, activity logging, security events + +## Project Structure + +``` +fraud_platform/ +├── accounts/ # User management +├── reports/ # Scam/fraud reports +├── osint/ # OSINT integration +├── moderation/ # Moderation system +├── analytics/ # Analytics and statistics +├── legal/ # Legal compliance tools +└── fraud_platform/ # Project settings +``` + +## Installation + +1. **Clone the repository** (if applicable) + +2. **Create virtual environment**: +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. **Install dependencies**: +```bash +pip install -r requirements.txt +``` + +4. **Set up environment variables**: +```bash +cp .env.example .env +# Edit .env with your configuration +``` + +5. **Set up PostgreSQL database**: +```bash +# Create database +createdb fraud_platform_db + +# Or using psql: +psql -U postgres +CREATE DATABASE fraud_platform_db; +``` + +6. **Run migrations**: +```bash +python manage.py makemigrations +python manage.py migrate +``` + +7. **Create superuser**: +```bash +python manage.py createsuperuser +``` + +8. **Run development server**: +```bash +python manage.py runserver +``` + +## Configuration + +### Database + +Update `.env` with your PostgreSQL credentials: +``` +DB_NAME=fraud_platform_db +DB_USER=postgres +DB_PASSWORD=your-password +DB_HOST=localhost +DB_PORT=5432 +``` + +### Email + +Configure email settings in `.env` for production: +``` +EMAIL_HOST=smtp.example.com +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER=your-email@example.com +EMAIL_HOST_PASSWORD=your-password +``` + +## Apps Overview + +### Accounts +- User registration and authentication +- Profile management +- Activity logging +- Failed login tracking + +### Reports +- Scam report submission +- Report listing and search +- Report verification +- Tag management + +### OSINT +- Background task processing +- OSINT data collection +- Result storage and analysis +- Service configuration + +### Moderation +- Moderation queue +- Report approval/rejection +- Moderation actions logging +- Automated rules + +### Analytics +- Report statistics +- User statistics +- OSINT statistics +- Dashboard views + +### Legal +- GDPR data requests +- Consent management +- Privacy policy +- Terms of service + +## Security Features + +- Strong password requirements (12+ characters) +- Multi-factor authentication (MFA) for admins/moderators +- Session security (HTTP-only, Secure cookies) +- CSRF protection +- XSS prevention +- SQL injection prevention +- Activity logging +- Security event tracking +- Rate limiting (to be configured) + +## Development + +### Running Tests +```bash +python manage.py test +``` + +### Creating Migrations +```bash +python manage.py makemigrations +python manage.py migrate +``` + +### Creating Superuser +```bash +python manage.py createsuperuser +``` + +## Production Deployment + +1. Set `DJANGO_ENV=production` in environment +2. Set `DEBUG=False` in `.env` +3. Configure proper `ALLOWED_HOSTS` +4. Set up SSL certificates +5. Configure production database +6. Set up static file serving +7. Configure email backend +8. Set up monitoring and logging + +## License + +[Your License Here] + +## Support + +For issues and questions, please contact [your contact information]. + diff --git a/SAMPLE_DATA.md b/SAMPLE_DATA.md new file mode 100644 index 0000000..9049ffa --- /dev/null +++ b/SAMPLE_DATA.md @@ -0,0 +1,142 @@ +# Sample Data Information + +This document contains information about the sample data created for testing the Fraud & Scam Reporting Platform. + +## Test Users + +### Administrator +- **Username:** `admin` +- **Password:** `admin123` +- **Email:** admin@fraudplatform.bg +- **Role:** Administrator +- **Access:** Full admin access, can manage users, view analytics, moderate reports + +### Moderators +- **Username:** `moderator1` +- **Password:** `mod123` +- **Email:** moderator1@fraudplatform.bg +- **Role:** Moderator + +- **Username:** `moderator2` +- **Password:** `mod123` +- **Email:** moderator2@fraudplatform.bg +- **Role:** Moderator + +### Normal Users +All normal users have the password: `user123` + +1. **john_doe** + - Email: john@example.com + - Name: John Doe + +2. **jane_smith** + - Email: jane@example.com + - Name: Jane Smith + +3. **ivan_petrov** + - Email: ivan@example.com + - Name: Ivan Petrov + +4. **maria_georgieva** + - Email: maria@example.com + - Name: Maria Georgieva + +5. **test_user** + - Email: test@example.com + - Name: Test User + +## Sample Scam Reports + +8 sample reports have been created with various statuses: + +### Verified Reports (5) +1. **Fake Bulgarian Bank Website** - Phishing scam +2. **Romance Scam on Dating Site** - Romance scam +3. **Fake Investment Opportunity** - Investment scam +4. **Tech Support Scam Call** - Tech support scam +5. **Fake Online Store** - Fake product scam +6. **Fake Job Offer** - Other scam type + +### Pending Review (1) +- **Phishing Email - Tax Refund** - Phishing scam + +### Under Review (1) +- **Advance Fee Fraud - Lottery Win** - Advance fee fraud + +## Sample Tags + +8 tags have been created: +- Phishing +- Fake Website +- Romance Scam +- Investment Scam +- Tech Support +- Identity Theft +- Fake Product +- Advance Fee + +## OSINT Data + +OSINT tasks and results have been created for the first 5 verified reports, including: +- WHOIS lookups +- DNS lookups +- SSL certificate checks +- Email analysis + +## Moderation Data + +- Moderation queue entries for pending reports +- Moderation actions for verified reports +- Assigned moderators for some reports + +## Analytics Data + +- Report statistics for the last 7 days +- User statistics for today + +## Usage + +To recreate sample data, run: +```bash +python manage.py create_sample_data +``` + +To clear existing data and recreate: +```bash +python manage.py create_sample_data --clear +``` + +## Testing Scenarios + +1. **Login as Admin:** + - View all reports + - Access analytics dashboard + - Manage users + - Moderate reports + +2. **Login as Moderator:** + - View moderation dashboard + - Review pending reports + - Approve/reject reports + - View OSINT results + +3. **Login as Normal User:** + - View verified reports + - Create new reports + - View own reports + - Edit/delete pending reports + +4. **Test MFA:** + - Enable MFA from profile + - Scan QR code with authenticator app + - Verify setup + - Test login with MFA + +## Notes + +- All sample users have email verification enabled +- All users have given consent (GDPR compliance) +- Reports have realistic Bulgarian context +- OSINT data is simulated for demonstration purposes +- Dates are randomized within the last 30 days + diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..74311e9 --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,47 @@ +""" +Admin configuration for accounts app. +""" +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from .models import User, UserProfile, ActivityLog, FailedLoginAttempt + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """Custom user admin.""" + list_display = ('username', 'email', 'role', 'is_verified', 'is_active', 'created_at') + list_filter = ('role', 'is_verified', 'is_active', 'created_at') + fieldsets = BaseUserAdmin.fieldsets + ( + ('Additional Info', {'fields': ('role', 'is_verified', 'mfa_enabled', 'last_login_ip')}), + ) + add_fieldsets = BaseUserAdmin.add_fieldsets + ( + ('Additional Info', {'fields': ('role', 'is_verified')}), + ) + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + """User profile admin.""" + list_display = ('user', 'first_name', 'last_name', 'consent_given', 'preferred_language') + list_filter = ('consent_given', 'preferred_language') + search_fields = ('user__username', 'user__email', 'first_name', 'last_name') + + +@admin.register(ActivityLog) +class ActivityLogAdmin(admin.ModelAdmin): + """Activity log admin.""" + list_display = ('user', 'action', 'ip_address', 'timestamp') + list_filter = ('action', 'timestamp') + search_fields = ('user__username', 'ip_address') + readonly_fields = ('user', 'action', 'ip_address', 'user_agent', 'details', 'timestamp') + date_hierarchy = 'timestamp' + + +@admin.register(FailedLoginAttempt) +class FailedLoginAttemptAdmin(admin.ModelAdmin): + """Failed login attempt admin.""" + list_display = ('email_or_username', 'ip_address', 'timestamp', 'is_blocked') + list_filter = ('is_blocked', 'timestamp') + search_fields = ('email_or_username', 'ip_address') + readonly_fields = ('email_or_username', 'ip_address', 'user_agent', 'timestamp') + date_hierarchy = 'timestamp' diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/accounts/form_mixins.py b/accounts/form_mixins.py new file mode 100644 index 0000000..a7c2247 --- /dev/null +++ b/accounts/form_mixins.py @@ -0,0 +1,141 @@ +""" +Form mixins for bot protection and validation. +""" +from django import forms +from django.core.cache import cache +from django.utils import timezone +from datetime import timedelta +import time + + +class HoneypotMixin: + """ + Honeypot field mixin - adds a hidden field that bots will fill but humans won't. + """ + # This field should be left empty by real users + website = forms.CharField( + required=False, + widget=forms.HiddenInput(attrs={'tabindex': '-1', 'autocomplete': 'off'}), + label='', # Empty label so screen readers skip it + ) + + def clean_website(self): + """If this field is filled, it's likely a bot.""" + website = self.cleaned_data.get('website') + if website: + raise forms.ValidationError('Bot detected. Please try again.') + return website + + +class TimeBasedValidationMixin: + """ + Time-based validation - prevents forms from being submitted too quickly (bot behavior). + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Add a hidden timestamp field + self.fields['form_timestamp'] = forms.CharField( + required=False, + widget=forms.HiddenInput(), + initial=str(time.time()) + ) + + def clean_form_timestamp(self): + """Validate that form wasn't submitted too quickly.""" + timestamp = self.cleaned_data.get('form_timestamp') + if not timestamp: + # If timestamp is missing, it might be a bot + raise forms.ValidationError('Invalid form submission.') + + try: + submit_time = float(timestamp) + current_time = time.time() + elapsed = current_time - submit_time + + # Forms submitted in less than 2 seconds are likely bots + if elapsed < 2: + raise forms.ValidationError('Form submitted too quickly. Please take your time.') + + # Forms submitted after 1 hour are likely stale + if elapsed > 3600: + raise forms.ValidationError('Form session expired. Please refresh and try again.') + except (ValueError, TypeError): + raise forms.ValidationError('Invalid form submission.') + + return timestamp + + +class RateLimitMixin: + """ + Rate limiting mixin - prevents too many form submissions from the same IP/user. + """ + def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request', None) + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + + if self.request: + # Get client IP + ip = self.get_client_ip(self.request) + + # Create a unique key for this form type + form_name = self.__class__.__name__ + cache_key = f'form_submission_{form_name}_{ip}' + + # Check submission count + submissions = cache.get(cache_key, 0) + + # Limit: 10 submissions per hour per IP + if submissions >= 10: + raise forms.ValidationError( + 'Too many submissions. Please wait before submitting again.' + ) + + # Increment counter + cache.set(cache_key, submissions + 1, 3600) # 1 hour + + return cleaned_data + + def get_client_ip(self, request): + """Get client IP address.""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +class BrowserFingerprintMixin: + """ + Browser fingerprint validation - ensures form is submitted from a real browser. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['user_agent_hash'] = forms.CharField( + required=False, + widget=forms.HiddenInput() + ) + + def clean_user_agent_hash(self): + """Validate user agent is present and reasonable.""" + ua_hash = self.cleaned_data.get('user_agent_hash') + + # If no user agent hash, it might be a bot + if not ua_hash: + raise forms.ValidationError('Invalid browser signature.') + + return ua_hash + + +class BotProtectionMixin(HoneypotMixin, TimeBasedValidationMixin, RateLimitMixin): + """ + Combined bot protection mixin that includes: + - Honeypot field + - Time-based validation + - Rate limiting + """ + pass + diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..e866921 --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,138 @@ +""" +Forms for accounts app. +""" +from django import forms +from django.contrib.auth.forms import UserCreationForm +from django_otp.plugins.otp_totp.models import TOTPDevice +from django.utils import timezone +from .models import User, UserProfile +from .security import InputSanitizer, PasswordSecurity +from .form_mixins import BotProtectionMixin + + +class UserRegistrationForm(BotProtectionMixin, UserCreationForm): + """User registration form with security validation and bot protection.""" + email = forms.EmailField(required=True) + consent_given = forms.BooleanField( + required=True, + label='I agree to the Privacy Policy and Terms of Service' + ) + + class Meta: + model = User + fields = ('username', 'email', 'password1', 'password2', 'consent_given') + + def clean_username(self): + username = self.cleaned_data.get('username') + if username: + # Sanitize username + username = InputSanitizer.sanitize_html(username) + # Check for SQL injection patterns + sanitized = InputSanitizer.sanitize_sql(username) + if sanitized is None: + raise forms.ValidationError('Invalid username format.') + return username + + def clean_email(self): + email = self.cleaned_data.get('email') + if email: + # Validate email format + if not InputSanitizer.validate_email(email): + raise forms.ValidationError('Invalid email format.') + # Sanitize email + email = InputSanitizer.sanitize_html(email) + return email + + def clean_password1(self): + password = self.cleaned_data.get('password1') + if password: + is_strong, message = PasswordSecurity.check_password_strength(password) + if not is_strong: + raise forms.ValidationError(message) + return password + + def save(self, commit=True): + user = super().save(commit=False) + user.email = self.cleaned_data['email'] + if commit: + user.save() + # Create profile with consent + profile = UserProfile.objects.create( + user=user, + consent_given=self.cleaned_data['consent_given'] + ) + return user + + +class UserProfileForm(forms.ModelForm): + """User profile edit form.""" + first_name = forms.CharField(max_length=100, required=False) + last_name = forms.CharField(max_length=100, required=False) + + class Meta: + model = UserProfile + fields = ('first_name', 'last_name', 'phone', 'preferred_language') + widgets = { + 'phone': forms.TextInput(attrs={'placeholder': '+359...'}), + } + + +class MFAVerifyForm(forms.Form): + """MFA verification form.""" + token = forms.CharField( + max_length=6, + min_length=6, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': '000000', + 'autofocus': True, + 'pattern': '[0-9]{6}', + 'maxlength': '6' + }), + label='Verification Code', + help_text='Enter the 6-digit code from your authenticator app' + ) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + def verify_token(self): + """Verify the TOTP token.""" + if not self.user: + return False + + token = self.cleaned_data.get('token') + if not token: + return False + + # Get the TOTP device (for login, use confirmed device; for setup, use unconfirmed) + try: + # Try confirmed device first (for login) + device = TOTPDevice.objects.get(user=self.user, name='default', confirmed=True) + except TOTPDevice.DoesNotExist: + # Try unconfirmed device (for setup) + try: + device = TOTPDevice.objects.get(user=self.user, name='default', confirmed=False) + except TOTPDevice.DoesNotExist: + return False + + return device.verify_token(token) + + +class MFASetupForm(forms.Form): + """MFA setup form (for confirmation).""" + token = forms.CharField( + max_length=6, + min_length=6, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': '000000', + 'autofocus': True, + 'pattern': '[0-9]{6}', + 'maxlength': '6' + }), + label='Verification Code', + help_text='Enter the 6-digit code from your authenticator app to confirm setup' + ) + diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/check_security.py b/accounts/management/commands/check_security.py new file mode 100644 index 0000000..f5b76bc --- /dev/null +++ b/accounts/management/commands/check_security.py @@ -0,0 +1,110 @@ +""" +Management command to check security settings and vulnerabilities. +""" +from django.core.management.base import BaseCommand +from django.conf import settings +from django.contrib.auth import get_user_model +from accounts.models import FailedLoginAttempt, ActivityLog +from django.utils import timezone +from datetime import timedelta + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Check security settings and report potential vulnerabilities' + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('=' * 60)) + self.stdout.write(self.style.SUCCESS('Security Audit Report')) + self.stdout.write(self.style.SUCCESS('=' * 60)) + + issues = [] + warnings = [] + + # Check DEBUG mode + if settings.DEBUG: + warnings.append('DEBUG mode is enabled - disable in production!') + else: + self.stdout.write(self.style.SUCCESS('✓ DEBUG mode is disabled')) + + # Check SECRET_KEY + if settings.SECRET_KEY == 'django-insecure-change-this-in-production': + issues.append('CRITICAL: Default SECRET_KEY is being used!') + else: + self.stdout.write(self.style.SUCCESS('✓ SECRET_KEY is set')) + + # Check ALLOWED_HOSTS + if not settings.ALLOWED_HOSTS: + issues.append('ALLOWED_HOSTS is empty - set in production!') + else: + self.stdout.write(self.style.SUCCESS(f'✓ ALLOWED_HOSTS: {settings.ALLOWED_HOSTS}')) + + # Check HTTPS settings + if not settings.DEBUG: + if not getattr(settings, 'SECURE_SSL_REDIRECT', False): + issues.append('SECURE_SSL_REDIRECT should be True in production') + else: + self.stdout.write(self.style.SUCCESS('✓ SSL redirect enabled')) + + # Check password hashers + if 'argon2' in settings.PASSWORD_HASHERS[0].lower(): + self.stdout.write(self.style.SUCCESS('✓ Using Argon2 password hasher')) + else: + warnings.append('Consider using Argon2 password hasher') + + # Check session security + if settings.SESSION_COOKIE_HTTPONLY: + self.stdout.write(self.style.SUCCESS('✓ Session cookies are HTTP-only')) + else: + issues.append('SESSION_COOKIE_HTTPONLY should be True') + + if settings.SESSION_COOKIE_SECURE or settings.DEBUG: + self.stdout.write(self.style.SUCCESS('✓ Session cookies are secure')) + else: + issues.append('SESSION_COOKIE_SECURE should be True in production') + + # Check CSRF protection + if settings.CSRF_COOKIE_HTTPONLY: + self.stdout.write(self.style.SUCCESS('✓ CSRF cookies are HTTP-only')) + else: + issues.append('CSRF_COOKIE_HTTPONLY should be True') + + # Check failed login attempts + recent_failures = FailedLoginAttempt.objects.filter( + timestamp__gte=timezone.now() - timedelta(hours=24) + ).count() + + if recent_failures > 0: + self.stdout.write(self.style.WARNING(f'⚠ {recent_failures} failed login attempts in last 24 hours')) + else: + self.stdout.write(self.style.SUCCESS('✓ No recent failed login attempts')) + + # Check for users with weak passwords (if possible) + users_without_mfa = User.objects.filter(mfa_enabled=False).count() + total_users = User.objects.count() + if total_users > 0: + mfa_percentage = (users_without_mfa / total_users) * 100 + if mfa_percentage > 50: + warnings.append(f'Only {100-mfa_percentage:.1f}% of users have MFA enabled') + else: + self.stdout.write(self.style.SUCCESS(f'✓ {100-mfa_percentage:.1f}% of users have MFA enabled')) + + # Report issues + if issues: + self.stdout.write(self.style.ERROR('\n' + '=' * 60)) + self.stdout.write(self.style.ERROR('CRITICAL ISSUES:')) + for issue in issues: + self.stdout.write(self.style.ERROR(f'✗ {issue}')) + + if warnings: + self.stdout.write(self.style.WARNING('\n' + '=' * 60)) + self.stdout.write(self.style.WARNING('WARNINGS:')) + for warning in warnings: + self.stdout.write(self.style.WARNING(f'⚠ {warning}')) + + if not issues and not warnings: + self.stdout.write(self.style.SUCCESS('\n✓ No security issues found!')) + + self.stdout.write(self.style.SUCCESS('\n' + '=' * 60)) + diff --git a/accounts/management/commands/create_initial_tags.py b/accounts/management/commands/create_initial_tags.py new file mode 100644 index 0000000..e7e373f --- /dev/null +++ b/accounts/management/commands/create_initial_tags.py @@ -0,0 +1,45 @@ +""" +Management command to create initial scam tags. +""" +from django.core.management.base import BaseCommand +from reports.models import ScamTag + + +class Command(BaseCommand): + help = 'Create initial scam tags' + + def handle(self, *args, **options): + tags = [ + {'name': 'Phishing', 'description': 'Phishing scams', 'color': '#dc3545'}, + {'name': 'Fake Website', 'description': 'Fake or fraudulent websites', 'color': '#fd7e14'}, + {'name': 'Romance Scam', 'description': 'Romance and dating scams', 'color': '#e83e8c'}, + {'name': 'Investment Scam', 'description': 'Investment and financial scams', 'color': '#ffc107'}, + {'name': 'Tech Support', 'description': 'Tech support scams', 'color': '#20c997'}, + {'name': 'Identity Theft', 'description': 'Identity theft attempts', 'color': '#6f42c1'}, + {'name': 'Fake Product', 'description': 'Fake product sales', 'color': '#17a2b8'}, + {'name': 'Advance Fee', 'description': 'Advance fee fraud', 'color': '#343a40'}, + ] + + created_count = 0 + for tag_data in tags: + tag, created = ScamTag.objects.get_or_create( + name=tag_data['name'], + defaults={ + 'description': tag_data['description'], + 'color': tag_data['color'] + } + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f'Created tag: {tag.name}') + ) + else: + self.stdout.write( + self.style.WARNING(f'Tag already exists: {tag.name}') + ) + + self.stdout.write( + self.style.SUCCESS(f'\nCreated {created_count} new tags.') + ) + diff --git a/accounts/management/commands/create_sample_data.py b/accounts/management/commands/create_sample_data.py new file mode 100644 index 0000000..d5ebcd2 --- /dev/null +++ b/accounts/management/commands/create_sample_data.py @@ -0,0 +1,377 @@ +""" +Management command to create sample data for testing. +""" +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from accounts.models import UserProfile, ActivityLog +from reports.models import ScamReport, ScamTag, ScamVerification +from osint.models import OSINTTask, OSINTResult +from moderation.models import ModerationQueue, ModerationAction +from analytics.models import ReportStatistic, UserStatistic +from legal.models import ConsentRecord +from django.utils import timezone +from datetime import timedelta +import random + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Create sample data for testing' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing data before creating sample data', + ) + + def handle(self, *args, **options): + if options['clear']: + self.stdout.write(self.style.WARNING('Clearing existing data...')) + ScamReport.objects.all().delete() + User.objects.filter(is_superuser=False).delete() + ScamTag.objects.all().delete() + + self.stdout.write(self.style.SUCCESS('Creating sample data...')) + + # Create users + users = self.create_users() + + # Create tags + tags = self.create_tags() + + # Create reports + reports = self.create_reports(users, tags) + + # Create OSINT data + self.create_osint_data(reports, users) + + # Create moderation data + self.create_moderation_data(reports, users) + + # Create analytics data + self.create_analytics_data() + + self.stdout.write(self.style.SUCCESS('\nSample data created successfully!')) + self.stdout.write(self.style.SUCCESS(f'Created {len(users)} users')) + self.stdout.write(self.style.SUCCESS(f'Created {len(tags)} tags')) + self.stdout.write(self.style.SUCCESS(f'Created {len(reports)} reports')) + + def create_users(self): + """Create sample users.""" + users = [] + + # Create admin user + admin, created = User.objects.get_or_create( + username='admin', + defaults={ + 'email': 'admin@fraudplatform.bg', + 'role': 'admin', + 'is_verified': True, + 'is_staff': True, + } + ) + if created: + admin.set_password('admin123') + admin.save() + UserProfile.objects.create( + user=admin, + first_name='Admin', + last_name='User', + consent_given=True, + consent_date=timezone.now() + ) + self.stdout.write(self.style.SUCCESS(f'Created admin user: {admin.username}')) + users.append(admin) + + # Create moderator users + for i in range(2): + mod, created = User.objects.get_or_create( + username=f'moderator{i+1}', + defaults={ + 'email': f'moderator{i+1}@fraudplatform.bg', + 'role': 'moderator', + 'is_verified': True, + } + ) + if created: + mod.set_password('mod123') + mod.save() + UserProfile.objects.create( + user=mod, + first_name=f'Moderator{i+1}', + last_name='User', + consent_given=True, + consent_date=timezone.now() + ) + self.stdout.write(self.style.SUCCESS(f'Created moderator: {mod.username}')) + users.append(mod) + + # Create normal users + user_data = [ + ('john_doe', 'john@example.com', 'John', 'Doe'), + ('jane_smith', 'jane@example.com', 'Jane', 'Smith'), + ('ivan_petrov', 'ivan@example.com', 'Ivan', 'Petrov'), + ('maria_georgieva', 'maria@example.com', 'Maria', 'Georgieva'), + ('test_user', 'test@example.com', 'Test', 'User'), + ] + + for username, email, first_name, last_name in user_data: + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': email, + 'role': 'normal', + 'is_verified': True, + } + ) + if created: + user.set_password('user123') + user.save() + UserProfile.objects.create( + user=user, + first_name=first_name, + last_name=last_name, + consent_given=True, + consent_date=timezone.now() + ) + self.stdout.write(self.style.SUCCESS(f'Created user: {user.username}')) + users.append(user) + + return users + + def create_tags(self): + """Create sample tags.""" + tag_data = [ + ('Phishing', 'Phishing scams', '#dc3545'), + ('Fake Website', 'Fake or fraudulent websites', '#fd7e14'), + ('Romance Scam', 'Romance and dating scams', '#e83e8c'), + ('Investment Scam', 'Investment and financial scams', '#ffc107'), + ('Tech Support', 'Tech support scams', '#20c997'), + ('Identity Theft', 'Identity theft attempts', '#6f42c1'), + ('Fake Product', 'Fake product sales', '#17a2b8'), + ('Advance Fee', 'Advance fee fraud', '#343a40'), + ] + + tags = [] + for name, description, color in tag_data: + tag, created = ScamTag.objects.get_or_create( + name=name, + defaults={ + 'description': description, + 'color': color + } + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created tag: {tag.name}')) + tags.append(tag) + + return tags + + def create_reports(self, users, tags): + """Create sample scam reports.""" + normal_users = [u for u in users if u.role == 'normal'] + if not normal_users: + return [] + + report_data = [ + { + 'title': 'Fake Bulgarian Bank Website', + 'description': 'I received an email claiming to be from my bank asking me to verify my account. The website looked identical to the real bank website but the URL was slightly different. When I entered my credentials, I realized it was a phishing attempt.', + 'scam_type': 'phishing', + 'reported_url': 'https://fake-bank-bg.com', + 'reported_email': 'support@fake-bank-bg.com', + 'status': 'verified', + 'verification_score': 95, + }, + { + 'title': 'Romance Scam on Dating Site', + 'description': 'Someone contacted me on a dating site and after weeks of chatting, asked me to send money for an emergency. I later found out this was a common romance scam pattern.', + 'scam_type': 'romance_scam', + 'reported_email': 'scammer@example.com', + 'reported_phone': '+359888123456', + 'status': 'verified', + 'verification_score': 88, + }, + { + 'title': 'Fake Investment Opportunity', + 'description': 'Received a call about a "guaranteed" investment opportunity with high returns. They asked for an upfront fee and promised unrealistic returns. This is clearly a scam.', + 'scam_type': 'investment_scam', + 'reported_phone': '+359888654321', + 'reported_company': 'Fake Investment Group', + 'status': 'verified', + 'verification_score': 92, + }, + { + 'title': 'Tech Support Scam Call', + 'description': 'Received a call from someone claiming to be from Microsoft tech support. They said my computer was infected and asked me to install remote access software. This is a known tech support scam.', + 'scam_type': 'tech_support_scam', + 'reported_phone': '+359888999888', + 'status': 'verified', + 'verification_score': 90, + }, + { + 'title': 'Fake Online Store', + 'description': 'Ordered a product from an online store that looked legitimate. After payment, I never received the product and the website disappeared. The products were fake listings.', + 'scam_type': 'fake_product', + 'reported_url': 'https://fake-store-bg.com', + 'reported_email': 'orders@fake-store-bg.com', + 'status': 'verified', + 'verification_score': 85, + }, + { + 'title': 'Phishing Email - Tax Refund', + 'description': 'Received an email claiming I was eligible for a tax refund. The email asked me to click a link and provide personal information. This is a phishing attempt.', + 'scam_type': 'phishing', + 'reported_email': 'tax-refund@scam.com', + 'status': 'pending', + 'verification_score': 0, + }, + { + 'title': 'Advance Fee Fraud - Lottery Win', + 'description': 'Received an email claiming I won a lottery I never entered. They asked for payment of "processing fees" to claim the prize. This is advance fee fraud.', + 'scam_type': 'advance_fee', + 'reported_email': 'lottery@scam.com', + 'status': 'under_review', + 'verification_score': 75, + }, + { + 'title': 'Fake Job Offer', + 'description': 'Received a job offer via email that seemed too good to be true. They asked for personal documents and bank account information before any interview. This is a scam.', + 'scam_type': 'other', + 'reported_email': 'hr@fake-company.com', + 'reported_url': 'https://fake-jobs-bg.com', + 'status': 'verified', + 'verification_score': 87, + }, + ] + + reports = [] + for i, data in enumerate(report_data): + reporter = random.choice(normal_users) + created_at = timezone.now() - timedelta(days=random.randint(1, 30)) + + report = ScamReport.objects.create( + reporter=reporter, + title=data['title'], + description=data['description'], + scam_type=data['scam_type'], + reported_url=data.get('reported_url', ''), + reported_email=data.get('reported_email', ''), + reported_phone=data.get('reported_phone', ''), + reported_company=data.get('reported_company', ''), + status=data['status'], + verification_score=data['verification_score'], + is_public=True if data['status'] == 'verified' else False, + is_anonymous=random.choice([True, False]), + created_at=created_at, + ) + + # Add random tags + report.tags.set(random.sample(tags, random.randint(1, 3))) + + if data['status'] == 'verified': + report.verified_at = created_at + timedelta(hours=random.randint(1, 48)) + report.save() + + reports.append(report) + self.stdout.write(self.style.SUCCESS(f'Created report: {report.title}')) + + return reports + + def create_osint_data(self, reports, users): + """Create sample OSINT data.""" + moderators = [u for u in users if u.role in ['moderator', 'admin']] + if not moderators or not reports: + return + + for report in reports[:5]: # Add OSINT data to first 5 reports + # Create OSINT tasks + task_types = ['whois_lookup', 'dns_lookup', 'ssl_check', 'email_analysis'] + for task_type in random.sample(task_types, 2): + OSINTTask.objects.create( + report=report, + task_type=task_type, + status='completed', + parameters={'target': report.reported_url or report.reported_email or report.reported_phone}, + result={'status': 'success', 'data': 'Sample OSINT data'}, + started_at=report.created_at + timedelta(minutes=5), + completed_at=report.created_at + timedelta(minutes=10), + ) + + # Create OSINT results + OSINTResult.objects.create( + report=report, + source='WHOIS Lookup', + data_type='whois', + raw_data={'domain': report.reported_url, 'registrar': 'Fake Registrar'}, + processed_data={'risk_level': 'high', 'domain_age': '30 days'}, + confidence_level=85, + is_verified=True, + ) + + self.stdout.write(self.style.SUCCESS(f'Created OSINT data for: {report.title}')) + + def create_moderation_data(self, reports, users): + """Create sample moderation data.""" + moderators = [u for u in users if u.role in ['moderator', 'admin']] + if not moderators or not reports: + return + + # Add pending reports to moderation queue + pending_reports = [r for r in reports if r.status == 'pending'] + for report in pending_reports: + ModerationQueue.objects.create( + report=report, + priority=random.choice(['low', 'normal', 'high']), + assigned_to=random.choice(moderators) if random.choice([True, False]) else None, + ) + + # Create moderation actions for verified reports + verified_reports = [r for r in reports if r.status == 'verified'] + for report in verified_reports: + moderator = random.choice(moderators) + ModerationAction.objects.create( + report=report, + moderator=moderator, + action_type='approve', + previous_status='pending', + new_status='verified', + reason='Verified through OSINT and manual review', + created_at=report.verified_at or report.created_at + timedelta(hours=1), + ) + + self.stdout.write(self.style.SUCCESS(f'Created moderation data for {len(reports)} reports')) + + def create_analytics_data(self): + """Create sample analytics data.""" + today = timezone.now().date() + + # Create report statistics for last 7 days + for i in range(7): + date = today - timedelta(days=i) + ReportStatistic.objects.get_or_create( + date=date, + defaults={ + 'total_reports': ScamReport.objects.filter(created_at__date=date).count(), + 'pending_reports': ScamReport.objects.filter(status='pending', created_at__date=date).count(), + 'verified_reports': ScamReport.objects.filter(status='verified', created_at__date=date).count(), + 'rejected_reports': ScamReport.objects.filter(status='rejected', created_at__date=date).count(), + } + ) + + # Create user statistics + UserStatistic.objects.get_or_create( + date=today, + defaults={ + 'total_users': User.objects.count(), + 'new_users': User.objects.filter(created_at__date=today).count(), + 'active_users': User.objects.filter(last_login__date=today).count(), + 'moderators': User.objects.filter(role__in=['moderator', 'admin']).count(), + 'admins': User.objects.filter(role='admin').count(), + } + ) + + self.stdout.write(self.style.SUCCESS('Created analytics data')) + diff --git a/accounts/management/commands/create_test_users.py b/accounts/management/commands/create_test_users.py new file mode 100644 index 0000000..77580ed --- /dev/null +++ b/accounts/management/commands/create_test_users.py @@ -0,0 +1,120 @@ +""" +Management command to create test users for dashboard testing. +""" +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from accounts.models import UserProfile +from django.utils import timezone + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Create test users (normal, moderator, admin) for dashboard testing' + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('Creating test users...')) + + # Create/Update Normal User + normal_user, created = User.objects.get_or_create( + username='normal_user', + defaults={ + 'email': 'normal@test.bg', + 'role': 'normal', + 'is_verified': True, + } + ) + normal_user.set_password('normal123') + normal_user.role = 'normal' + normal_user.is_verified = True + normal_user.save() + + if not hasattr(normal_user, 'profile'): + UserProfile.objects.create( + user=normal_user, + first_name='Normal', + last_name='User', + consent_given=True, + consent_date=timezone.now() + ) + + self.stdout.write(self.style.SUCCESS( + f'{"Created" if created else "Updated"} normal user: {normal_user.username} (password: normal123)' + )) + + # Create/Update Moderator User + moderator_user, created = User.objects.get_or_create( + username='moderator', + defaults={ + 'email': 'moderator@test.bg', + 'role': 'moderator', + 'is_verified': True, + } + ) + moderator_user.set_password('moderator123') + moderator_user.role = 'moderator' + moderator_user.is_verified = True + moderator_user.save() + + if not hasattr(moderator_user, 'profile'): + UserProfile.objects.create( + user=moderator_user, + first_name='Moderator', + last_name='User', + consent_given=True, + consent_date=timezone.now() + ) + + self.stdout.write(self.style.SUCCESS( + f'{"Created" if created else "Updated"} moderator user: {moderator_user.username} (password: moderator123)' + )) + + # Create/Update Admin User + admin_user, created = User.objects.get_or_create( + username='admin', + defaults={ + 'email': 'admin@test.bg', + 'role': 'admin', + 'is_verified': True, + 'is_staff': True, + 'is_superuser': True, + } + ) + admin_user.set_password('admin123') + admin_user.role = 'admin' + admin_user.is_verified = True + admin_user.is_staff = True + admin_user.is_superuser = True + admin_user.save() + + if not hasattr(admin_user, 'profile'): + UserProfile.objects.create( + user=admin_user, + first_name='Admin', + last_name='User', + consent_given=True, + consent_date=timezone.now() + ) + + self.stdout.write(self.style.SUCCESS( + f'{"Created" if created else "Updated"} admin user: {admin_user.username} (password: admin123)' + )) + + self.stdout.write(self.style.SUCCESS('\n' + '='*60)) + self.stdout.write(self.style.SUCCESS('Test Users Created Successfully!')) + self.stdout.write(self.style.SUCCESS('='*60)) + self.stdout.write(self.style.SUCCESS('\nLogin Credentials:')) + self.stdout.write(self.style.SUCCESS('\n1. Normal User:')) + self.stdout.write(self.style.SUCCESS(' Username: normal_user')) + self.stdout.write(self.style.SUCCESS(' Password: normal123')) + self.stdout.write(self.style.SUCCESS(' Dashboard: /reports/my-reports/')) + self.stdout.write(self.style.SUCCESS('\n2. Moderator:')) + self.stdout.write(self.style.SUCCESS(' Username: moderator')) + self.stdout.write(self.style.SUCCESS(' Password: moderator123')) + self.stdout.write(self.style.SUCCESS(' Dashboard: /moderation/dashboard/')) + self.stdout.write(self.style.SUCCESS('\n3. Administrator:')) + self.stdout.write(self.style.SUCCESS(' Username: admin')) + self.stdout.write(self.style.SUCCESS(' Password: admin123')) + self.stdout.write(self.style.SUCCESS(' Dashboard: /analytics/dashboard/')) + self.stdout.write(self.style.SUCCESS('\n' + '='*60)) + diff --git a/accounts/middleware.py b/accounts/middleware.py new file mode 100644 index 0000000..5d0e8c8 --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,279 @@ +""" +Security middleware for enhanced protection. +""" +import time +from django.core.cache import cache +from django.http import HttpResponseForbidden, JsonResponse +from django.utils.deprecation import MiddlewareMixin +from django.contrib.auth import logout +from django.contrib import messages +from accounts.models import FailedLoginAttempt, ActivityLog +from django.utils import timezone +from datetime import timedelta, datetime + + +class RateLimitMiddleware(MiddlewareMixin): + """ + Rate limiting middleware to prevent brute force attacks. + """ + def process_request(self, request): + # Skip rate limiting for static files and admin + if request.path.startswith('/static/') or request.path.startswith('/media/'): + return None + + # Get client IP + ip = self.get_client_ip(request) + + # Rate limit login attempts + if request.path == '/accounts/login/' and request.method == 'POST': + cache_key = f'login_attempts_{ip}' + attempts = cache.get(cache_key, 0) + + if attempts >= 5: # Max 5 attempts per 15 minutes + # Log failed attempt + FailedLoginAttempt.objects.create( + email_or_username=request.POST.get('username', ''), + ip_address=ip, + user_agent=request.META.get('HTTP_USER_AGENT', ''), + is_blocked=True + ) + return JsonResponse({ + 'error': 'Too many login attempts. Please try again in 15 minutes.' + }, status=429) + + # Increment attempts + cache.set(cache_key, attempts + 1, 900) # 15 minutes + + # Rate limit registration + if request.path == '/accounts/register/' and request.method == 'POST': + cache_key = f'register_attempts_{ip}' + attempts = cache.get(cache_key, 0) + + if attempts >= 3: # Max 3 registrations per hour + return JsonResponse({ + 'error': 'Too many registration attempts. Please try again later.' + }, status=429) + + cache.set(cache_key, attempts + 1, 3600) # 1 hour + + # Rate limit report creation + if request.path.startswith('/reports/create/') and request.method == 'POST': + if request.user.is_authenticated: + cache_key = f'report_creation_{request.user.id}' + attempts = cache.get(cache_key, 0) + + if attempts >= 10: # Max 10 reports per hour + return JsonResponse({ + 'error': 'Too many reports created. Please try again later.' + }, status=429) + + cache.set(cache_key, attempts + 1, 3600) # 1 hour + + return None + + def get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +class SecurityHeadersMiddleware(MiddlewareMixin): + """ + Add security headers to all responses. + """ + def process_response(self, request, response): + # Content Security Policy + csp = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: https:; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self';" + ) + response['Content-Security-Policy'] = csp + + # X-Content-Type-Options + response['X-Content-Type-Options'] = 'nosniff' + + # X-Frame-Options + response['X-Frame-Options'] = 'DENY' + + # Referrer Policy + response['Referrer-Policy'] = 'strict-origin-when-cross-origin' + + # Permissions Policy + response['Permissions-Policy'] = ( + 'geolocation=(), microphone=(), camera=(), ' + 'payment=(), usb=(), magnetometer=(), gyroscope=()' + ) + + # X-XSS-Protection (legacy but still useful) + response['X-XSS-Protection'] = '1; mode=block' + + # Remove server header + if 'Server' in response: + del response['Server'] + + return response + + +class IPWhitelistMiddleware(MiddlewareMixin): + """ + IP whitelist/blacklist middleware (optional, for admin access). + """ + def process_request(self, request): + # Only apply to admin area + if not request.path.startswith('/admin/'): + return None + + ip = self.get_client_ip(request) + + # Get blacklisted IPs from cache or database + blacklisted = cache.get(f'blacklisted_ip_{ip}', False) + + if blacklisted: + return HttpResponseForbidden('Access denied.') + + return None + + def get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +class SecurityLoggingMiddleware(MiddlewareMixin): + """ + Log security-related events. + """ + def process_response(self, request, response): + # Log suspicious activities + if response.status_code == 403: + self.log_security_event(request, 'FORBIDDEN_ACCESS', { + 'path': request.path, + 'method': request.method, + 'status': 403 + }) + + if response.status_code == 429: + self.log_security_event(request, 'RATE_LIMIT_EXCEEDED', { + 'path': request.path, + 'method': request.method, + }) + + # Log failed login attempts + if request.path == '/accounts/login/' and request.method == 'POST': + if response.status_code != 200 or (hasattr(response, 'content') and b'error' in response.content): + self.log_security_event(request, 'FAILED_LOGIN', { + 'username': request.POST.get('username', ''), + }) + + return response + + def log_security_event(self, request, event_type, details): + """Log security event to ActivityLog.""" + try: + ip = self.get_client_ip(request) + # Convert non-JSON-serializable objects to strings + serializable_details = self.make_json_serializable({ + 'event_type': event_type, + **details + }) + ActivityLog.objects.create( + user=request.user if hasattr(request, 'user') and request.user.is_authenticated else None, + action='security_event', + ip_address=ip, + user_agent=request.META.get('HTTP_USER_AGENT', ''), + details=serializable_details + ) + except Exception: + pass # Don't break the request if logging fails + + def make_json_serializable(self, obj): + """Convert non-JSON-serializable objects to strings.""" + import json + from datetime import datetime, date + from decimal import Decimal + + if isinstance(obj, dict): + return {k: self.make_json_serializable(v) for k, v in obj.items()} + elif isinstance(obj, (list, tuple)): + return [self.make_json_serializable(item) for item in obj] + elif isinstance(obj, (datetime, date)): + return obj.isoformat() + elif isinstance(obj, Decimal): + return float(obj) + elif hasattr(obj, '__dict__'): + return str(obj) + else: + try: + json.dumps(obj) # Test if it's JSON serializable + return obj + except (TypeError, ValueError): + return str(obj) + + def get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +class SessionSecurityMiddleware(MiddlewareMixin): + """ + Enhanced session security. + """ + def process_request(self, request): + # Check if user attribute exists (AuthenticationMiddleware must run first) + if hasattr(request, 'user') and request.user.is_authenticated: + # Check for session hijacking + current_ip = self.get_client_ip(request) + session_ip = request.session.get('ip_address') + + if session_ip and session_ip != current_ip: + # IP changed - potential session hijacking + logout(request) + messages.error(request, 'Security alert: Session terminated due to IP change.') + return None + + # Store IP in session + request.session['ip_address'] = current_ip + + # Check session age + session_age = request.session.get('created_at') + if session_age: + # Convert string back to datetime if needed + if isinstance(session_age, str): + from django.utils.dateparse import parse_datetime + session_age = parse_datetime(session_age) + if isinstance(session_age, datetime): + age = timezone.now() - session_age + if age > timedelta(hours=24): # Max 24 hours + logout(request) + messages.info(request, 'Your session has expired. Please log in again.') + return None + else: + request.session['created_at'] = timezone.now().isoformat() + + return None + + def get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..726ea4d --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,114 @@ +# Generated by Django 5.2.8 on 2025-11-26 13:41 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('role', models.CharField(choices=[('normal', 'Normal User'), ('moderator', 'Moderator'), ('admin', 'Administrator')], default='normal', help_text='User role in the system', max_length=20)), + ('is_verified', models.BooleanField(default=False, help_text='Email verification status')), + ('mfa_enabled', models.BooleanField(default=False, help_text='Multi-factor authentication enabled')), + ('mfa_secret', models.CharField(blank=True, help_text='MFA secret key', max_length=32, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('last_login_ip', models.GenericIPAddressField(blank=True, null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'users_user', + 'ordering': ['-created_at'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='FailedLoginAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email_or_username', models.CharField(max_length=255)), + ('ip_address', models.GenericIPAddressField()), + ('user_agent', models.TextField(blank=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('is_blocked', models.BooleanField(default=False)), + ], + options={ + 'verbose_name': 'Failed Login Attempt', + 'verbose_name_plural': 'Failed Login Attempts', + 'db_table': 'security_failedlogin', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['email_or_username', 'timestamp'], name='security_fa_email_o_e830a6_idx'), models.Index(fields=['ip_address', 'timestamp'], name='security_fa_ip_addr_d5cb75_idx')], + }, + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(blank=True, max_length=100)), + ('last_name', models.CharField(blank=True, max_length=100)), + ('phone', models.CharField(blank=True, help_text='Encrypted phone number', max_length=17, null=True, validators=[django.core.validators.RegexValidator(message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.", regex='^\\+?1?\\d{9,15}$')])), + ('date_of_birth', models.DateField(blank=True, null=True)), + ('consent_given', models.BooleanField(default=False)), + ('consent_date', models.DateTimeField(blank=True, null=True)), + ('consent_ip', models.GenericIPAddressField(blank=True, null=True)), + ('preferred_language', models.CharField(choices=[('bg', 'Bulgarian'), ('en', 'English')], default='bg', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'User Profile', + 'verbose_name_plural': 'User Profiles', + 'db_table': 'users_userprofile', + }, + ), + migrations.CreateModel( + name='ActivityLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('login', 'Login'), ('logout', 'Logout'), ('register', 'Registration'), ('password_change', 'Password Change'), ('profile_update', 'Profile Update'), ('report_create', 'Report Created'), ('report_edit', 'Report Edited'), ('report_delete', 'Report Deleted')], max_length=50)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('details', models.JSONField(blank=True, default=dict)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_logs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Activity Log', + 'verbose_name_plural': 'Activity Logs', + 'db_table': 'users_activitylog', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['user', 'timestamp'], name='users_activ_user_id_049bc2_idx'), models.Index(fields=['action', 'timestamp'], name='users_activ_action_cdfe71_idx')], + }, + ), + ] diff --git a/accounts/migrations/0002_alter_activitylog_action.py b/accounts/migrations/0002_alter_activitylog_action.py new file mode 100644 index 0000000..03e782c --- /dev/null +++ b/accounts/migrations/0002_alter_activitylog_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-11-26 14:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='activitylog', + name='action', + field=models.CharField(choices=[('login', 'Login'), ('logout', 'Logout'), ('register', 'Registration'), ('password_change', 'Password Change'), ('profile_update', 'Profile Update'), ('report_create', 'Report Created'), ('report_edit', 'Report Edited'), ('report_delete', 'Report Deleted'), ('security_event', 'Security Event'), ('failed_login', 'Failed Login'), ('suspicious_activity', 'Suspicious Activity')], max_length=50), + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..04a04a0 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,175 @@ +""" +User management models for the fraud reporting platform. +""" +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils import timezone +from django.core.validators import RegexValidator + + +class User(AbstractUser): + """ + Custom user model with role-based access control. + """ + ROLE_CHOICES = [ + ('normal', 'Normal User'), + ('moderator', 'Moderator'), + ('admin', 'Administrator'), + ] + + role = models.CharField( + max_length=20, + choices=ROLE_CHOICES, + default='normal', + help_text='User role in the system' + ) + is_verified = models.BooleanField( + default=False, + help_text='Email verification status' + ) + mfa_enabled = models.BooleanField( + default=False, + help_text='Multi-factor authentication enabled' + ) + mfa_secret = models.CharField( + max_length=32, + blank=True, + null=True, + help_text='MFA secret key' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_login_ip = models.GenericIPAddressField(null=True, blank=True) + + class Meta: + db_table = 'users_user' + verbose_name = 'User' + verbose_name_plural = 'Users' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.username} ({self.role})" + + def is_moderator(self): + return self.role in ['moderator', 'admin'] + + def is_administrator(self): + return self.role == 'admin' + + +class UserProfile(models.Model): + """ + Extended user profile information. + """ + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='profile' + ) + first_name = models.CharField(max_length=100, blank=True) + last_name = models.CharField(max_length=100, blank=True) + + phone_regex = RegexValidator( + regex=r'^\+?1?\d{9,15}$', + message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed." + ) + phone = models.CharField( + validators=[phone_regex], + max_length=17, + blank=True, + null=True, + help_text='Encrypted phone number' + ) + date_of_birth = models.DateField(null=True, blank=True) + + # GDPR Consent + consent_given = models.BooleanField(default=False) + consent_date = models.DateTimeField(null=True, blank=True) + consent_ip = models.GenericIPAddressField(null=True, blank=True) + + # Preferences + preferred_language = models.CharField( + max_length=10, + default='bg', + choices=[('bg', 'Bulgarian'), ('en', 'English')] + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'users_userprofile' + verbose_name = 'User Profile' + verbose_name_plural = 'User Profiles' + + def __str__(self): + return f"Profile of {self.user.username}" + + +class ActivityLog(models.Model): + """ + Log user activities for security and auditing. + """ + ACTION_CHOICES = [ + ('login', 'Login'), + ('logout', 'Logout'), + ('register', 'Registration'), + ('password_change', 'Password Change'), + ('profile_update', 'Profile Update'), + ('report_create', 'Report Created'), + ('report_edit', 'Report Edited'), + ('report_delete', 'Report Deleted'), + ('security_event', 'Security Event'), + ('failed_login', 'Failed Login'), + ('suspicious_activity', 'Suspicious Activity'), + ] + + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='activity_logs' + ) + action = models.CharField(max_length=50, choices=ACTION_CHOICES) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True) + details = models.JSONField(default=dict, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'users_activitylog' + verbose_name = 'Activity Log' + verbose_name_plural = 'Activity Logs' + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['user', 'timestamp']), + models.Index(fields=['action', 'timestamp']), + ] + + def __str__(self): + return f"{self.user} - {self.action} at {self.timestamp}" + + +class FailedLoginAttempt(models.Model): + """ + Track failed login attempts for security. + """ + email_or_username = models.CharField(max_length=255) + ip_address = models.GenericIPAddressField() + user_agent = models.TextField(blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + is_blocked = models.BooleanField(default=False) + + class Meta: + db_table = 'security_failedlogin' + verbose_name = 'Failed Login Attempt' + verbose_name_plural = 'Failed Login Attempts' + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['email_or_username', 'timestamp']), + models.Index(fields=['ip_address', 'timestamp']), + ] + + def __str__(self): + return f"Failed login: {self.email_or_username} from {self.ip_address}" diff --git a/accounts/security.py b/accounts/security.py new file mode 100644 index 0000000..d666c0d --- /dev/null +++ b/accounts/security.py @@ -0,0 +1,134 @@ +""" +Security utilities for encryption and data protection. +""" +from cryptography.fernet import Fernet +from django.conf import settings +import base64 +import hashlib +import os + + +class DataEncryption: + """ + Encrypt/decrypt sensitive data. + """ + @staticmethod + def get_encryption_key(): + """Get or generate encryption key.""" + key = getattr(settings, 'ENCRYPTION_KEY', None) + if not key: + # Generate a key (in production, this should be in environment) + key = Fernet.generate_key() + elif isinstance(key, str): + key = key.encode() + return key + + @staticmethod + def encrypt(data): + """Encrypt sensitive data.""" + if not data: + return data + try: + key = DataEncryption.get_encryption_key() + f = Fernet(key) + encrypted = f.encrypt(data.encode() if isinstance(data, str) else data) + return base64.urlsafe_b64encode(encrypted).decode() + except Exception: + return data # Return original if encryption fails + + @staticmethod + def decrypt(encrypted_data): + """Decrypt sensitive data.""" + if not encrypted_data: + return encrypted_data + try: + key = DataEncryption.get_encryption_key() + f = Fernet(key) + decoded = base64.urlsafe_b64decode(encrypted_data.encode()) + decrypted = f.decrypt(decoded) + return decrypted.decode() + except Exception: + return encrypted_data # Return original if decryption fails + + +class InputSanitizer: + """ + Sanitize user input to prevent XSS and injection attacks. + """ + @staticmethod + def sanitize_html(text): + """Remove potentially dangerous HTML.""" + if not text: + return text + + import html + # Escape HTML entities + text = html.escape(text) + return text + + @staticmethod + def sanitize_sql(text): + """Basic SQL injection prevention (Django ORM handles this, but extra check).""" + if not text: + return text + + # Remove SQL keywords + dangerous = ['DROP', 'DELETE', 'INSERT', 'UPDATE', 'SELECT', 'UNION', '--', ';'] + text_upper = text.upper() + for keyword in dangerous: + if keyword in text_upper: + # Log potential SQL injection attempt + return None + return text + + @staticmethod + def validate_email(email): + """Validate email format.""" + import re + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) + + @staticmethod + def validate_url(url): + """Validate URL format.""" + import re + pattern = r'^https?://[^\s/$.?#].[^\s]*$' + return bool(re.match(pattern, url)) + + +class PasswordSecurity: + """ + Enhanced password security utilities. + """ + @staticmethod + def check_password_strength(password): + """Check password strength.""" + if len(password) < 12: + return False, "Password must be at least 12 characters long" + + if not any(c.isupper() for c in password): + return False, "Password must contain at least one uppercase letter" + + if not any(c.islower() for c in password): + return False, "Password must contain at least one lowercase letter" + + if not any(c.isdigit() for c in password): + return False, "Password must contain at least one number" + + if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password): + return False, "Password must contain at least one special character" + + # Check for common patterns + common_patterns = ['123456', 'password', 'qwerty', 'abc123'] + password_lower = password.lower() + for pattern in common_patterns: + if pattern in password_lower: + return False, "Password contains common patterns" + + return True, "Password is strong" + + @staticmethod + def hash_sensitive_data(data): + """Hash sensitive data for storage.""" + return hashlib.sha256(data.encode()).hexdigest() + diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..df3f1a7 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,33 @@ +""" +URL configuration for accounts app. +""" +from django.urls import path +from django.contrib.auth import views as auth_views +from . import views + +app_name = 'accounts' + +urlpatterns = [ + # Authentication + path('login/', views.LoginView.as_view(), name='login'), + path('logout/', views.LogoutView.as_view(), name='logout'), + path('register/', views.RegisterView.as_view(), name='register'), + + # MFA + path('mfa/verify/', views.MFAVerifyView.as_view(), name='mfa_verify'), + path('mfa/setup/', views.MFASetupView.as_view(), name='mfa_setup'), + path('mfa/enable/', views.MFAEnableView.as_view(), name='mfa_enable'), + path('mfa/disable/', views.MFADisableView.as_view(), name='mfa_disable'), + + # Profile + path('profile/', views.ProfileView.as_view(), name='profile'), + path('profile/edit/', views.ProfileEditView.as_view(), name='profile_edit'), + + # Password management + path('password/change/', views.PasswordChangeView.as_view(), name='password_change'), + path('password/reset/', views.PasswordResetView.as_view(), name='password_reset'), + path('password/reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'), + path('password/reset/confirm///', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('password/reset/complete/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), +] + diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..175685e --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,433 @@ +""" +Views for accounts app. +""" +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth import login, logout +from django.contrib.auth.views import LoginView, PasswordChangeView, PasswordResetView, PasswordResetConfirmView, PasswordResetDoneView +from django.views.generic import CreateView, UpdateView, DetailView, TemplateView, FormView, View +from django.urls import reverse_lazy +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib.messages import success, error +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator +from django_otp import devices_for_user +from django_otp.plugins.otp_totp.models import TOTPDevice +from django_otp.decorators import otp_required +from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse +import qrcode +import qrcode.image.svg +from io import BytesIO +import base64 +from .models import User, UserProfile, ActivityLog, FailedLoginAttempt +from .forms import UserRegistrationForm, UserProfileForm, MFAVerifyForm, MFASetupForm +from .security import InputSanitizer, PasswordSecurity +from django.core.cache import cache +from django.utils import timezone +from datetime import timedelta + + +class RegisterView(SuccessMessageMixin, CreateView): + """User registration view with security checks.""" + model = User + form_class = UserRegistrationForm + template_name = 'accounts/register.html' + success_url = reverse_lazy('accounts:profile') + success_message = "Registration successful! Please verify your email." + + def get_form_kwargs(self): + """Pass request to form for rate limiting.""" + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def form_valid(self, form): + # Sanitize input + if form.cleaned_data.get('email'): + form.cleaned_data['email'] = InputSanitizer.sanitize_html(form.cleaned_data['email']) + + # Check password strength + password = form.cleaned_data.get('password1') + is_strong, message = PasswordSecurity.check_password_strength(password) + if not is_strong: + form.add_error('password1', message) + return self.form_invalid(form) + + response = super().form_valid(form) + login(self.request, self.object) + + # Log activity + ActivityLog.objects.create( + user=self.object, + action='register', + ip_address=self.get_client_ip(), + user_agent=self.request.META.get('HTTP_USER_AGENT', '') + ) + return response + + def get_client_ip(self): + x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = self.request.META.get('REMOTE_ADDR') + return ip + + +def csrf_failure(request, reason=""): + """Custom CSRF failure view.""" + from django.contrib import messages + messages.error(request, 'Security error: Invalid request. Please try again.') + return redirect('accounts:login') + + +class LoginView(SuccessMessageMixin, LoginView): + """Custom login view with MFA support and security checks.""" + template_name = 'accounts/login.html' + redirect_authenticated_user = True + success_message = "Welcome back!" + + def form_valid(self, form): + # Check for account lockout + username = form.cleaned_data.get('username') + ip = self.get_client_ip() + + # Check if IP is blocked + if self.is_ip_blocked(ip): + error(self.request, 'Too many failed login attempts. Please try again later.') + return redirect('accounts:login') + + # Check if user account is locked + try: + user = User.objects.get(username=username) + if self.is_account_locked(user): + error(self.request, 'Account temporarily locked due to too many failed attempts.') + return redirect('accounts:login') + except User.DoesNotExist: + pass # Don't reveal if user exists + + # Authenticate user first + response = super().form_valid(form) + user = self.request.user + + # Clear failed login attempts on successful login + FailedLoginAttempt.objects.filter( + email_or_username=username, + ip_address=ip + ).delete() + cache.delete(f'login_attempts_{ip}') + + # Check if MFA is enabled + if user.mfa_enabled: + # Store user ID in session for MFA verification + self.request.session['mfa_user_id'] = user.id + # Don't log them in yet - redirect to MFA verification + logout(self.request) + return redirect('accounts:mfa_verify') + + # Log activity for non-MFA users + ActivityLog.objects.create( + user=user, + action='login', + ip_address=ip, + user_agent=self.request.META.get('HTTP_USER_AGENT', '') + ) + return response + + def form_invalid(self, form): + # Log failed login attempt + username = form.data.get('username', '') + ip = self.get_client_ip() + + FailedLoginAttempt.objects.create( + email_or_username=username, + ip_address=ip, + user_agent=self.request.META.get('HTTP_USER_AGENT', '') + ) + + # Check if should block IP + failed_attempts = FailedLoginAttempt.objects.filter( + ip_address=ip, + timestamp__gte=timezone.now() - timedelta(minutes=15) + ).count() + + if failed_attempts >= 10: + cache.set(f'blocked_ip_{ip}', True, 3600) # Block for 1 hour + + return super().form_invalid(form) + + def is_ip_blocked(self, ip): + """Check if IP is blocked.""" + return cache.get(f'blocked_ip_{ip}', False) + + def is_account_locked(self, user): + """Check if user account is locked.""" + failed_attempts = FailedLoginAttempt.objects.filter( + email_or_username=user.username, + timestamp__gte=timezone.now() - timedelta(minutes=30) + ).count() + return failed_attempts >= 5 + + def get_client_ip(self): + x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = self.request.META.get('REMOTE_ADDR') + return ip + + +class LogoutView(View): + """Custom logout view that accepts GET requests.""" + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated: + # Log activity + ActivityLog.objects.create( + user=request.user, + action='logout', + ip_address=self.get_client_ip(request), + user_agent=request.META.get('HTTP_USER_AGENT', '') + ) + logout(request) + success(request, "You have been logged out successfully.") + return redirect('reports:home') + + def post(self, request, *args, **kwargs): + return self.get(request, *args, **kwargs) + + def get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +class ProfileView(DetailView): + """User profile view.""" + model = User + template_name = 'accounts/profile.html' + context_object_name = 'user_obj' + + def get_object(self): + return self.request.user + + +class ProfileEditView(SuccessMessageMixin, UpdateView): + """Edit user profile.""" + model = UserProfile + form_class = UserProfileForm + template_name = 'accounts/profile_edit.html' + success_url = reverse_lazy('accounts:profile') + success_message = "Profile updated successfully!" + + def get_object(self): + profile, created = UserProfile.objects.get_or_create(user=self.request.user) + return profile + + def form_valid(self, form): + response = super().form_valid(form) + # Log activity + ActivityLog.objects.create( + user=self.request.user, + action='profile_update', + ip_address=self.get_client_ip(), + user_agent=self.request.META.get('HTTP_USER_AGENT', '') + ) + return response + + def get_client_ip(self): + x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = self.request.META.get('REMOTE_ADDR') + return ip + + +class PasswordChangeView(SuccessMessageMixin, PasswordChangeView): + """Change password view.""" + template_name = 'accounts/password_change.html' + success_url = reverse_lazy('accounts:profile') + success_message = "Password changed successfully!" + + +class PasswordResetView(SuccessMessageMixin, PasswordResetView): + """Password reset view.""" + template_name = 'accounts/password_reset.html' + email_template_name = 'accounts/password_reset_email.html' + subject_template_name = 'accounts/password_reset_email_subject.txt' + success_url = reverse_lazy('accounts:password_reset_done') + success_message = "Password reset email sent!" + + def form_valid(self, form): + """Override to use SiteSettings for from_email.""" + try: + from reports.models import SiteSettings + site_settings = SiteSettings.get_settings() + self.from_email = site_settings.default_from_email + except: + pass # Fallback to default + return super().form_valid(form) + + def get_email_context_data(self, **kwargs): + """Override to ensure site_name is set for email template.""" + context = super().get_email_context_data(**kwargs) + # Ensure site_name is set (Django uses sites framework, but provide fallback) + if 'site_name' not in context or not context['site_name']: + context['site_name'] = 'Портал за Докладване на Измами' + return context + + +class PasswordResetDoneView(TemplateView): + """Password reset done view - shows confirmation that email was sent.""" + template_name = 'accounts/password_reset_done.html' + + +class PasswordResetConfirmView(SuccessMessageMixin, PasswordResetConfirmView): + """Password reset confirmation view.""" + template_name = 'accounts/password_reset_confirm.html' + success_url = reverse_lazy('accounts:password_reset_complete') + success_message = "Password reset successful!" + + +class MFAVerifyView(FormView): + """MFA verification view after login.""" + template_name = 'accounts/mfa_verify.html' + form_class = MFAVerifyForm + + def dispatch(self, request, *args, **kwargs): + # Check if user ID is in session + if 'mfa_user_id' not in request.session: + error(request, 'Please log in first.') + return redirect('accounts:login') + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + user_id = self.request.session.get('mfa_user_id') + if user_id: + kwargs['user'] = get_object_or_404(User, id=user_id) + return kwargs + + def form_valid(self, form): + user_id = self.request.session.get('mfa_user_id') + user = get_object_or_404(User, id=user_id) + + # Verify the token + if form.verify_token(): + # Login the user + login(self.request, user) + # Clear the session + del self.request.session['mfa_user_id'] + # Log activity + ActivityLog.objects.create( + user=user, + action='login', + ip_address=self.get_client_ip(), + user_agent=self.request.META.get('HTTP_USER_AGENT', '') + ) + success(self.request, 'Login successful!') + return redirect('accounts:profile') + else: + error(self.request, 'Invalid verification code. Please try again.') + return self.form_invalid(form) + + def get_client_ip(self): + x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = self.request.META.get('REMOTE_ADDR') + return ip + + +@method_decorator(login_required, name='dispatch') +class MFASetupView(TemplateView): + """MFA setup view.""" + template_name = 'accounts/mfa_setup.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user = self.request.user + + # Delete any existing unconfirmed devices + TOTPDevice.objects.filter(user=user, confirmed=False).delete() + + # Create new TOTP device + device = TOTPDevice.objects.create( + user=user, + name='default', + confirmed=False + ) + + # Generate QR code + config_url = device.config_url + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(config_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buffer = BytesIO() + img.save(buffer, format='PNG') + img_str = base64.b64encode(buffer.getvalue()).decode() + context['qr_code'] = f'data:image/png;base64,{img_str}' + context['secret_key'] = device.key + context['device'] = device + context['mfa_enabled'] = user.mfa_enabled + return context + + +@method_decorator(login_required, name='dispatch') +class MFAEnableView(FormView): + """Enable MFA after verification.""" + template_name = 'accounts/mfa_enable.html' + form_class = MFAVerifyForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + user = self.request.user + + # Verify the token + if form.verify_token(): + # Enable MFA + user.mfa_enabled = True + user.save() + + # Confirm the device + device = TOTPDevice.objects.get(user=user, name='default') + device.confirmed = True + device.save() + + success(self.request, 'MFA has been enabled successfully!') + return redirect('accounts:profile') + else: + error(self.request, 'Invalid verification code. Please try again.') + return self.form_invalid(form) + + +@method_decorator(login_required, name='dispatch') +class MFADisableView(TemplateView): + """Disable MFA.""" + template_name = 'accounts/mfa_disable.html' + + def post(self, request, *args, **kwargs): + user = request.user + user.mfa_enabled = False + user.mfa_secret = '' + user.save() + + # Delete TOTP devices + TOTPDevice.objects.filter(user=user).delete() + + success(request, 'MFA has been disabled.') + return redirect('accounts:profile') + + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) diff --git a/analytics/__init__.py b/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics/admin.py b/analytics/admin.py new file mode 100644 index 0000000..f115653 --- /dev/null +++ b/analytics/admin.py @@ -0,0 +1,32 @@ +""" +Admin configuration for analytics app. +""" +from django.contrib import admin +from .models import ReportStatistic, UserStatistic, OSINTStatistic + + +@admin.register(ReportStatistic) +class ReportStatisticAdmin(admin.ModelAdmin): + """Report statistic admin.""" + list_display = ('date', 'total_reports', 'verified_reports', 'pending_reports') + list_filter = ('date',) + date_hierarchy = 'date' + readonly_fields = ('created_at', 'updated_at') + + +@admin.register(UserStatistic) +class UserStatisticAdmin(admin.ModelAdmin): + """User statistic admin.""" + list_display = ('date', 'total_users', 'new_users', 'active_users') + list_filter = ('date',) + date_hierarchy = 'date' + readonly_fields = ('created_at', 'updated_at') + + +@admin.register(OSINTStatistic) +class OSINTStatisticAdmin(admin.ModelAdmin): + """OSINT statistic admin.""" + list_display = ('date', 'total_tasks', 'completed_tasks', 'average_confidence') + list_filter = ('date',) + date_hierarchy = 'date' + readonly_fields = ('created_at', 'updated_at') diff --git a/analytics/apps.py b/analytics/apps.py new file mode 100644 index 0000000..258d4dd --- /dev/null +++ b/analytics/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AnalyticsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'analytics' diff --git a/analytics/migrations/0001_initial.py b/analytics/migrations/0001_initial.py new file mode 100644 index 0000000..9b939ec --- /dev/null +++ b/analytics/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.8 on 2025-11-26 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='OSINTStatistic', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(unique=True)), + ('total_tasks', models.IntegerField(default=0)), + ('completed_tasks', models.IntegerField(default=0)), + ('failed_tasks', models.IntegerField(default=0)), + ('average_confidence', models.FloatField(default=0.0, help_text='Average confidence score')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'OSINT Statistic', + 'verbose_name_plural': 'OSINT Statistics', + 'db_table': 'analytics_osintstatistic', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='UserStatistic', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(unique=True)), + ('total_users', models.IntegerField(default=0)), + ('new_users', models.IntegerField(default=0)), + ('active_users', models.IntegerField(default=0, help_text='Users who logged in')), + ('moderators', models.IntegerField(default=0)), + ('admins', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'User Statistic', + 'verbose_name_plural': 'User Statistics', + 'db_table': 'analytics_userstatistic', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='ReportStatistic', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(unique=True)), + ('total_reports', models.IntegerField(default=0)), + ('pending_reports', models.IntegerField(default=0)), + ('verified_reports', models.IntegerField(default=0)), + ('rejected_reports', models.IntegerField(default=0)), + ('phishing_count', models.IntegerField(default=0)), + ('fake_website_count', models.IntegerField(default=0)), + ('romance_scam_count', models.IntegerField(default=0)), + ('investment_scam_count', models.IntegerField(default=0)), + ('tech_support_scam_count', models.IntegerField(default=0)), + ('identity_theft_count', models.IntegerField(default=0)), + ('fake_product_count', models.IntegerField(default=0)), + ('advance_fee_count', models.IntegerField(default=0)), + ('other_count', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Report Statistic', + 'verbose_name_plural': 'Report Statistics', + 'db_table': 'analytics_reportstatistic', + 'ordering': ['-date'], + 'indexes': [models.Index(fields=['date'], name='analytics_r_date_0bacbd_idx')], + }, + ), + ] diff --git a/analytics/migrations/__init__.py b/analytics/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics/models.py b/analytics/models.py new file mode 100644 index 0000000..12225a3 --- /dev/null +++ b/analytics/models.py @@ -0,0 +1,98 @@ +""" +Analytics and statistics models. +""" +from django.db import models +from django.contrib.auth import get_user_model +from reports.models import ScamReport + +User = get_user_model() + + +class ReportStatistic(models.Model): + """ + Aggregated statistics for reports. + """ + date = models.DateField(unique=True) + total_reports = models.IntegerField(default=0) + pending_reports = models.IntegerField(default=0) + verified_reports = models.IntegerField(default=0) + rejected_reports = models.IntegerField(default=0) + + # By scam type + phishing_count = models.IntegerField(default=0) + fake_website_count = models.IntegerField(default=0) + romance_scam_count = models.IntegerField(default=0) + investment_scam_count = models.IntegerField(default=0) + tech_support_scam_count = models.IntegerField(default=0) + identity_theft_count = models.IntegerField(default=0) + fake_product_count = models.IntegerField(default=0) + advance_fee_count = models.IntegerField(default=0) + other_count = models.IntegerField(default=0) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'analytics_reportstatistic' + verbose_name = 'Report Statistic' + verbose_name_plural = 'Report Statistics' + ordering = ['-date'] + indexes = [ + models.Index(fields=['date']), + ] + + def __str__(self): + return f"Statistics for {self.date}" + + +class UserStatistic(models.Model): + """ + User activity statistics. + """ + date = models.DateField(unique=True) + total_users = models.IntegerField(default=0) + new_users = models.IntegerField(default=0) + active_users = models.IntegerField( + default=0, + help_text='Users who logged in' + ) + moderators = models.IntegerField(default=0) + admins = models.IntegerField(default=0) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'analytics_userstatistic' + verbose_name = 'User Statistic' + verbose_name_plural = 'User Statistics' + ordering = ['-date'] + + def __str__(self): + return f"User Statistics for {self.date}" + + +class OSINTStatistic(models.Model): + """ + OSINT task and result statistics. + """ + date = models.DateField(unique=True) + total_tasks = models.IntegerField(default=0) + completed_tasks = models.IntegerField(default=0) + failed_tasks = models.IntegerField(default=0) + average_confidence = models.FloatField( + default=0.0, + help_text='Average confidence score' + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'analytics_osintstatistic' + verbose_name = 'OSINT Statistic' + verbose_name_plural = 'OSINT Statistics' + ordering = ['-date'] + + def __str__(self): + return f"OSINT Statistics for {self.date}" diff --git a/analytics/tests.py b/analytics/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/analytics/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/analytics/urls.py b/analytics/urls.py new file mode 100644 index 0000000..1780c1f --- /dev/null +++ b/analytics/urls.py @@ -0,0 +1,14 @@ +""" +URL configuration for analytics app. +""" +from django.urls import path +from . import views + +app_name = 'analytics' + +urlpatterns = [ + path('', views.AnalyticsDashboardView.as_view(), name='dashboard'), + path('reports/', views.ReportAnalyticsView.as_view(), name='reports'), + path('users/', views.UserAnalyticsView.as_view(), name='users'), +] + diff --git a/analytics/views.py b/analytics/views.py new file mode 100644 index 0000000..0738052 --- /dev/null +++ b/analytics/views.py @@ -0,0 +1,174 @@ +""" +Views for analytics app. +""" +from django.views.generic import TemplateView +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import Count, Q, Avg, Max, Min +from django.utils import timezone +from django.utils.safestring import mark_safe +import json +from datetime import timedelta +from reports.models import ScamReport +from accounts.models import User, ActivityLog +from osint.models import OSINTTask +from moderation.models import ModerationAction + + +class AdminRequiredMixin(UserPassesTestMixin): + """Mixin to require admin role.""" + def test_func(self): + return self.request.user.is_authenticated and self.request.user.is_administrator() + + +class AnalyticsDashboardView(LoginRequiredMixin, AdminRequiredMixin, TemplateView): + """Analytics dashboard.""" + template_name = 'analytics/dashboard.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Report statistics + context['total_reports'] = ScamReport.objects.count() + context['pending_reports'] = ScamReport.objects.filter(status='pending').count() + context['verified_reports'] = ScamReport.objects.filter(status='verified').count() + context['rejected_reports'] = ScamReport.objects.filter(status='rejected').count() + + # Scam type breakdown with display names and percentages + scam_types_data = ScamReport.objects.values('scam_type').annotate( + count=Count('id') + ).order_by('-count') + + scam_types_list = [] + total_reports = context.get('total_reports', 0) + for item in scam_types_data: + scam_type_key = item['scam_type'] + display_name = dict(ScamReport.SCAM_TYPE_CHOICES).get(scam_type_key, scam_type_key) + percentage = (item['count'] / total_reports * 100) if total_reports > 0 else 0 + scam_types_list.append({ + 'scam_type': scam_type_key, + 'display_name': display_name, + 'count': item['count'], + 'percentage': round(percentage, 1) + }) + context['scam_types'] = scam_types_list + + # User statistics + context['total_users'] = User.objects.count() + context['moderators'] = User.objects.filter(role__in=['moderator', 'admin']).count() + + # OSINT statistics + context['osint_tasks'] = OSINTTask.objects.count() + context['completed_tasks'] = OSINTTask.objects.filter(status='completed').count() + + return context + + +class ReportAnalyticsView(LoginRequiredMixin, AdminRequiredMixin, TemplateView): + """Report analytics.""" + template_name = 'analytics/reports.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Overall statistics + context['total_reports'] = ScamReport.objects.count() + context['pending_reports'] = ScamReport.objects.filter(status='pending').count() + context['verified_reports'] = ScamReport.objects.filter(status='verified').count() + context['rejected_reports'] = ScamReport.objects.filter(status='rejected').count() + context['under_review_reports'] = ScamReport.objects.filter(status='under_review').count() + + # Scam type distribution + scam_types_data = ScamReport.objects.values('scam_type').annotate( + count=Count('id') + ).order_by('-count') + + scam_types_list = [] + for item in scam_types_data: + scam_type_key = item['scam_type'] + display_name = dict(ScamReport.SCAM_TYPE_CHOICES).get(scam_type_key, scam_type_key) + percentage = (item['count'] / context['total_reports'] * 100) if context['total_reports'] > 0 else 0 + scam_types_list.append({ + 'scam_type': scam_type_key, + 'display_name': display_name, + 'count': item['count'], + 'percentage': round(percentage, 1) + }) + context['scam_types'] = scam_types_list + + # Time-based statistics + now = timezone.now() + last_7_days = now - timedelta(days=7) + last_30_days = now - timedelta(days=30) + last_90_days = now - timedelta(days=90) + + context['reports_last_7_days'] = ScamReport.objects.filter(created_at__gte=last_7_days).count() + context['reports_last_30_days'] = ScamReport.objects.filter(created_at__gte=last_30_days).count() + context['reports_last_90_days'] = ScamReport.objects.filter(created_at__gte=last_90_days).count() + + # Daily reports for the last 30 days + daily_reports = [] + for i in range(29, -1, -1): # From 29 days ago to today + date = now - timedelta(days=i) + count = ScamReport.objects.filter( + created_at__date=date.date() + ).count() + daily_reports.append({ + 'date': date.date().isoformat(), + 'count': count + }) + context['daily_reports'] = mark_safe(json.dumps(daily_reports)) + + # Average moderation time + verified_reports = ScamReport.objects.filter( + status='verified', + verified_at__isnull=False + ) + if verified_reports.exists(): + moderation_times = [] + for report in verified_reports: + if report.created_at and report.verified_at: + time_diff = report.verified_at - report.created_at + moderation_times.append(time_diff.total_seconds() / 3600) # Convert to hours + + if moderation_times: + context['avg_moderation_time_hours'] = round(sum(moderation_times) / len(moderation_times), 2) + context['min_moderation_time_hours'] = round(min(moderation_times), 2) + context['max_moderation_time_hours'] = round(max(moderation_times), 2) + + # Top reporters + top_reporters = ScamReport.objects.values( + 'reporter__username', + 'reporter__email' + ).annotate( + report_count=Count('id') + ).order_by('-report_count')[:10] + context['top_reporters'] = top_reporters + + # Moderation statistics + context['total_moderations'] = ModerationAction.objects.count() + context['approvals'] = ModerationAction.objects.filter(action_type='approve').count() + context['rejections'] = ModerationAction.objects.filter(action_type='reject').count() + + # Reports by status over time + status_over_time = [] + for i in range(6, -1, -1): # From 6 days ago to today + date = now - timedelta(days=i) + status_over_time.append({ + 'date': date.date().isoformat(), + 'pending': ScamReport.objects.filter(status='pending', created_at__date=date.date()).count(), + 'verified': ScamReport.objects.filter(status='verified', created_at__date=date.date()).count(), + 'rejected': ScamReport.objects.filter(status='rejected', created_at__date=date.date()).count(), + }) + context['status_over_time'] = mark_safe(json.dumps(status_over_time)) + + return context + + +class UserAnalyticsView(LoginRequiredMixin, AdminRequiredMixin, TemplateView): + """User analytics.""" + template_name = 'analytics/users.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Add detailed user analytics + return context diff --git a/fraud_platform/__init__.py b/fraud_platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fraud_platform/asgi.py b/fraud_platform/asgi.py new file mode 100644 index 0000000..6e2c077 --- /dev/null +++ b/fraud_platform/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for fraud_platform project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fraud_platform.settings') + +application = get_asgi_application() diff --git a/fraud_platform/context_processors.py b/fraud_platform/context_processors.py new file mode 100644 index 0000000..48d88be --- /dev/null +++ b/fraud_platform/context_processors.py @@ -0,0 +1,71 @@ +""" +Context processors for SEO and site-wide data. +""" +from django.conf import settings +from reports.models import SiteSettings + + +def seo_context(request): + """ + Provides SEO-related context variables for all templates. + """ + site_url = request.build_absolute_uri('/').rstrip('/') + + # Get site settings + site_settings = SiteSettings.get_settings() + + # Default SEO values + default_seo = { + 'site_name': 'Портал за Докладване на Измами', + 'site_description': 'Портал за докладване на измами. Защита на гражданите от онлайн измами.', + 'site_keywords': 'измами, киберпрестъпления, докладване измами, защита потребители, България, официален портал, анти-измами, сигурност онлайн', + 'site_author': 'Официален Портал - Република България', + 'site_language': 'bg', + 'site_url': site_url, + 'site_image': f'{site_url}/static/images/logo.svg', + 'twitter_site': '@fraudplatformbg', + 'twitter_creator': '@fraudplatformbg', + } + + # Get page-specific SEO from view context if available + page_seo = { + 'page_title': getattr(request, 'seo_title', None), + 'page_description': getattr(request, 'seo_description', None), + 'page_keywords': getattr(request, 'seo_keywords', None), + 'page_image': getattr(request, 'seo_image', None), + 'page_type': getattr(request, 'seo_type', 'website'), + 'canonical_url': getattr(request, 'canonical_url', request.build_absolute_uri()), + } + + # Merge defaults with page-specific + seo = {**default_seo, **{k: v for k, v in page_seo.items() if v}} + + # Build full title + if seo.get('page_title'): + seo['full_title'] = f"{seo['page_title']} | {seo['site_name']}" + else: + seo['full_title'] = seo['site_name'] + + # Use page image or default + seo['og_image'] = seo.get('page_image') or seo['site_image'] + + return { + 'seo': seo, + 'site_settings': site_settings, + } + + +def email_settings(request): + """ + Provides email settings context (for use in settings if needed). + """ + from reports.models import SiteSettings + site_settings = SiteSettings.get_settings() + + return { + 'email_settings': { + 'default_from_email': site_settings.default_from_email, + 'contact_email': site_settings.contact_email, + } + } + diff --git a/fraud_platform/settings/__init__.py b/fraud_platform/settings/__init__.py new file mode 100644 index 0000000..dfda800 --- /dev/null +++ b/fraud_platform/settings/__init__.py @@ -0,0 +1,9 @@ +import os +from .base import * + +# Import environment-specific settings +if os.environ.get('DJANGO_ENV') == 'production': + from .production import * +else: + from .development import * + diff --git a/fraud_platform/settings/base.py b/fraud_platform/settings/base.py new file mode 100644 index 0000000..f7c1c55 --- /dev/null +++ b/fraud_platform/settings/base.py @@ -0,0 +1,241 @@ +""" +Base settings for fraud_platform project. +""" +import os +from pathlib import Path +import environ + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +# Initialize environment variables +env = environ.Env( + DEBUG=(bool, False) +) + +# Read .env file if it exists +environ.Env.read_env(os.path.join(BASE_DIR, '.env')) + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = env('SECRET_KEY', default='django-insecure-change-this-in-production') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env('DEBUG', default=False) + +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # MFA/OTP + 'django_otp', + 'django_otp.plugins.otp_totp', + 'django_otp.plugins.otp_static', + + # Local apps + 'accounts', + 'reports', + 'osint', + 'moderation', + 'analytics', + 'legal', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'accounts.middleware.SecurityHeadersMiddleware', # Security headers + 'accounts.middleware.RateLimitMiddleware', # Rate limiting + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', # MFA middleware + 'accounts.middleware.SessionSecurityMiddleware', # Session security (after auth) + 'accounts.middleware.SecurityLoggingMiddleware', # Security logging + 'accounts.middleware.IPWhitelistMiddleware', # IP filtering (admin) + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'fraud_platform.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'fraud_platform.context_processors.seo_context', + ], + }, + }, +] + +WSGI_APPLICATION = 'fraud_platform.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': env('DB_NAME', default='fraud_platform_db'), + 'USER': env('DB_USER', default='postgres'), + 'PASSWORD': env('DB_PASSWORD', default=''), + 'HOST': env('DB_HOST', default='localhost'), + 'PORT': env('DB_PORT', default='5432'), + } +} + +# Custom User Model +AUTH_USER_MODEL = 'accounts.User' + +# Password validation - Enhanced security +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + 'OPTIONS': { + 'user_attributes': ('username', 'email', 'first_name', 'last_name'), + 'max_similarity': 0.7, + } + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 12, + } + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Additional password requirements +PASSWORD_MIN_LENGTH = 12 +PASSWORD_REQUIRE_UPPERCASE = True +PASSWORD_REQUIRE_LOWERCASE = True +PASSWORD_REQUIRE_DIGITS = True +PASSWORD_REQUIRE_SPECIAL = True + +# Internationalization +LANGUAGE_CODE = 'bg' # Bulgarian +TIME_ZONE = 'Europe/Sofia' +USE_I18N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [ + BASE_DIR / 'static', +] + +# Media files +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Security Settings +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = 'DENY' +SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_SSL_REDIRECT = not DEBUG +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# Session Security +SESSION_COOKIE_SECURE = not DEBUG +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_AGE = 86400 # 24 hours +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_SAVE_EVERY_REQUEST = True # Extend session on activity + +# CSRF Security +CSRF_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_HTTPONLY = True +CSRF_COOKIE_SAMESITE = 'Lax' +CSRF_USE_SESSIONS = True # Store CSRF token in session instead of cookie +CSRF_FAILURE_VIEW = 'accounts.views.csrf_failure' + +# Password Security +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.Argon2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', +] + +# Encryption Key (should be in environment in production) +ENCRYPTION_KEY = env('ENCRYPTION_KEY', default=None) + +# Rate Limiting +RATELIMIT_ENABLE = True +RATELIMIT_USE_CACHE = 'default' + +# Security Logging +SECURITY_LOG_FAILED_LOGINS = True +SECURITY_LOG_SUSPICIOUS_ACTIVITY = True + +# Login URLs +LOGIN_URL = '/accounts/login/' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' + +# Email Configuration +# Uses custom backend that reads from SiteSettings model +# Falls back to environment variables if SiteSettings not configured +EMAIL_BACKEND = env('EMAIL_BACKEND', default='reports.email_backend.SiteSettingsEmailBackend') +EMAIL_HOST = env('EMAIL_HOST', default='') +EMAIL_PORT = env('EMAIL_PORT', default=587) +EMAIL_USE_TLS = env('EMAIL_USE_TLS', default=True) +EMAIL_USE_SSL = env('EMAIL_USE_SSL', default=False) +EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='') +# DEFAULT_FROM_EMAIL is now managed via SiteSettings model +# Fallback to env variable if SiteSettings not configured +DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@fraudplatform.bg') +EMAIL_TIMEOUT = env('EMAIL_TIMEOUT', default=10) + +# File Upload Settings - Security +FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB +DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB +FILE_UPLOAD_PERMISSIONS = 0o644 +DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000 # Prevent DoS via form fields + +# Allowed file types for uploads +ALLOWED_FILE_EXTENSIONS = ['.pdf', '.jpg', '.jpeg', '.png', '.txt', '.doc', '.docx'] +ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +] + +# Database Security +if 'default' in DATABASES: + DATABASES['default']['CONN_MAX_AGE'] = 600 # Connection pooling + DATABASES['default']['OPTIONS'] = { + 'connect_timeout': 10, + 'options': '-c statement_timeout=30000' # 30 second query timeout + } + diff --git a/fraud_platform/settings/development.py b/fraud_platform/settings/development.py new file mode 100644 index 0000000..d16dc51 --- /dev/null +++ b/fraud_platform/settings/development.py @@ -0,0 +1,33 @@ +""" +Development settings for fraud_platform project. +""" +from .base import * +import os + +DEBUG = True + +ALLOWED_HOSTS = ['localhost', '127.0.0.1'] + +# Use SQLite for development if PostgreSQL is not available +if os.environ.get('USE_SQLITE', 'True').lower() == 'true': + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + +# Development-specific settings (django_extensions is optional) +# INSTALLED_APPS += [ +# 'django_extensions', # Optional: for development tools +# ] + +# Use SiteSettings email backend (will use console if SMTP not configured) +# EMAIL_BACKEND is set in base.py to use SiteSettingsEmailBackend +# This allows admin to configure SMTP even in development + +# Disable security features for development +SECURE_SSL_REDIRECT = False +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False + diff --git a/fraud_platform/settings/production.py b/fraud_platform/settings/production.py new file mode 100644 index 0000000..c422135 --- /dev/null +++ b/fraud_platform/settings/production.py @@ -0,0 +1,54 @@ +""" +Production settings for fraud_platform project. +""" +from .base import * + +DEBUG = False + +# Production security settings +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True + +# Logging +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'django.log', + 'formatter': 'verbose', + }, + 'security_file': { + 'level': 'WARNING', + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'security.log', + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['file'], + 'level': 'INFO', + 'propagate': True, + }, + 'django.security': { + 'handlers': ['security_file'], + 'level': 'WARNING', + 'propagate': True, + }, + }, +} + diff --git a/fraud_platform/sitemaps.py b/fraud_platform/sitemaps.py new file mode 100644 index 0000000..c12634e --- /dev/null +++ b/fraud_platform/sitemaps.py @@ -0,0 +1,54 @@ +""" +Sitemap configuration for SEO. +""" +from django.contrib.sitemaps import Sitemap +from django.urls import reverse +from reports.models import ScamReport + + +class StaticViewSitemap(Sitemap): + """Sitemap for static pages.""" + priority = 1.0 + changefreq = 'monthly' + + def items(self): + return [ + 'reports:home', + 'reports:list', + 'reports:create', + 'reports:contact', + 'reports:search', + 'legal:privacy', + 'legal:terms', + ] + + def location(self, item): + return reverse(item) + + +class ScamReportSitemap(Sitemap): + """Sitemap for scam reports.""" + changefreq = 'weekly' + priority = 0.8 + + def items(self): + # Only include public, verified reports + return ScamReport.objects.filter( + is_public=True, + status='verified' + ).order_by('-created_at') + + def lastmod(self, obj): + return obj.updated_at or obj.created_at + + def location(self, obj): + from django.urls import reverse + return reverse('reports:detail', kwargs={'pk': obj.pk}) + + +# Combine sitemaps +sitemaps = { + 'static': StaticViewSitemap, + 'reports': ScamReportSitemap, +} + diff --git a/fraud_platform/urls.py b/fraud_platform/urls.py new file mode 100644 index 0000000..86ec394 --- /dev/null +++ b/fraud_platform/urls.py @@ -0,0 +1,57 @@ +""" +URL configuration for fraud_platform project. +""" +from django.contrib import admin +from django.contrib.sitemaps.views import sitemap +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.http import HttpResponse, Http404 +from django.views.decorators.cache import cache_control +from .sitemaps import sitemaps +import os + +@cache_control(max_age=86400) # Cache for 1 day +def favicon_view(request): + """Serve favicon.ico""" + favicon_path = os.path.join(settings.BASE_DIR, 'static', 'favicon.ico') + if os.path.exists(favicon_path): + with open(favicon_path, 'rb') as f: + return HttpResponse(f.read(), content_type='image/x-icon') + raise Http404("Favicon not found") + +@cache_control(max_age=86400) # Cache for 1 day +def robots_txt(request): + """Serve robots.txt""" + content = """User-agent: * +Allow: / +Disallow: /admin/ +Disallow: /accounts/ +Disallow: /moderation/ +Disallow: /analytics/ +Disallow: /osint/admin-dashboard/ +Disallow: /api/ + +Sitemap: {}/sitemap.xml +""".format(request.build_absolute_uri('/').rstrip('/')) + return HttpResponse(content, content_type='text/plain') + +urlpatterns = [ + path('admin/', admin.site.urls), + path('accounts/', include('accounts.urls')), + path('osint/', include('osint.urls')), + path('moderation/', include('moderation.urls')), + path('analytics/', include('analytics.urls')), + path('legal/', include('legal.urls')), + path('', include('reports.urls')), # Home page and reports (reports:home, reports:list, etc.) + # SEO + path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), + path('robots.txt', robots_txt, name='robots'), + # Favicon + path('favicon.ico', favicon_view, name='favicon'), +] + +# Serve media files in development +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/fraud_platform/wsgi.py b/fraud_platform/wsgi.py new file mode 100644 index 0000000..e1f4a24 --- /dev/null +++ b/fraud_platform/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for fraud_platform project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fraud_platform.settings') + +application = get_wsgi_application() diff --git a/legal/__init__.py b/legal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legal/admin.py b/legal/admin.py new file mode 100644 index 0000000..4bd3e88 --- /dev/null +++ b/legal/admin.py @@ -0,0 +1,47 @@ +""" +Admin configuration for legal app. +""" +from django.contrib import admin +from .models import ConsentRecord, DataRequest, SecurityEvent + + +@admin.register(ConsentRecord) +class ConsentRecordAdmin(admin.ModelAdmin): + """Consent record admin.""" + list_display = ('user', 'consent_type', 'consent_given', 'timestamp', 'version') + list_filter = ('consent_type', 'consent_given', 'timestamp') + search_fields = ('user__username', 'user__email') + readonly_fields = ('timestamp',) + date_hierarchy = 'timestamp' + + +@admin.register(DataRequest) +class DataRequestAdmin(admin.ModelAdmin): + """Data request admin.""" + list_display = ('user', 'request_type', 'status', 'requested_at', 'completed_at', 'handled_by') + list_filter = ('request_type', 'status', 'requested_at') + search_fields = ('user__username', 'user__email', 'description') + readonly_fields = ('requested_at',) + date_hierarchy = 'requested_at' + + fieldsets = ( + ('Request Information', { + 'fields': ('user', 'request_type', 'status', 'description') + }), + ('Response', { + 'fields': ('response_data', 'response_file', 'notes') + }), + ('Handling', { + 'fields': ('handled_by', 'requested_at', 'completed_at') + }), + ) + + +@admin.register(SecurityEvent) +class SecurityEventAdmin(admin.ModelAdmin): + """Security event admin.""" + list_display = ('event_type', 'user', 'severity', 'ip_address', 'timestamp', 'resolved') + list_filter = ('event_type', 'severity', 'resolved', 'timestamp') + search_fields = ('user__username', 'ip_address') + readonly_fields = ('timestamp',) + date_hierarchy = 'timestamp' diff --git a/legal/apps.py b/legal/apps.py new file mode 100644 index 0000000..372b98b --- /dev/null +++ b/legal/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LegalConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'legal' diff --git a/legal/forms.py b/legal/forms.py new file mode 100644 index 0000000..bfc93b5 --- /dev/null +++ b/legal/forms.py @@ -0,0 +1,22 @@ +""" +Forms for legal app. +""" +from django import forms +from .models import DataRequest + + +class DataRequestForm(forms.ModelForm): + """Form for GDPR data requests.""" + + class Meta: + model = DataRequest + fields = ['request_type', 'description'] + widgets = { + 'request_type': forms.Select(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 5, + 'placeholder': 'Additional details about your request...' + }), + } + diff --git a/legal/migrations/0001_initial.py b/legal/migrations/0001_initial.py new file mode 100644 index 0000000..f6a4c04 --- /dev/null +++ b/legal/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2.8 on 2025-11-26 13:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ConsentRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('consent_type', models.CharField(choices=[('privacy_policy', 'Privacy Policy'), ('terms_of_service', 'Terms of Service'), ('data_processing', 'Data Processing'), ('marketing', 'Marketing Communications'), ('cookies', 'Cookie Consent')], max_length=50)), + ('consent_given', models.BooleanField(default=False)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('version', models.CharField(blank=True, help_text='Version of the policy/terms', max_length=20)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consents', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Consent Record', + 'verbose_name_plural': 'Consent Records', + 'db_table': 'legal_consentrecord', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['user', 'consent_type'], name='legal_conse_user_id_c707fa_idx'), models.Index(fields=['consent_type', 'timestamp'], name='legal_conse_consent_216033_idx')], + }, + ), + migrations.CreateModel( + name='DataRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('request_type', models.CharField(choices=[('access', 'Data Access Request'), ('deletion', 'Data Deletion Request'), ('portability', 'Data Portability Request'), ('rectification', 'Data Rectification Request'), ('objection', 'Objection to Processing'), ('restriction', 'Restriction of Processing')], max_length=50)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('description', models.TextField(blank=True, help_text='Additional details about the request')), + ('requested_at', models.DateTimeField(auto_now_add=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('response_data', models.JSONField(blank=True, default=dict, help_text='Response data (e.g., exported data)')), + ('response_file', models.FileField(blank=True, help_text='File containing requested data', null=True, upload_to='data_requests/')), + ('notes', models.TextField(blank=True, help_text='Internal notes about handling the request')), + ('handled_by', models.ForeignKey(blank=True, limit_choices_to={'role': 'admin'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='handled_data_requests', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='data_requests', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Data Request', + 'verbose_name_plural': 'Data Requests', + 'db_table': 'legal_datarequest', + 'ordering': ['-requested_at'], + 'indexes': [models.Index(fields=['user', 'status'], name='legal_datar_user_id_da1063_idx'), models.Index(fields=['request_type', 'status'], name='legal_datar_request_ad226f_idx'), models.Index(fields=['status', 'requested_at'], name='legal_datar_status_392f15_idx')], + }, + ), + migrations.CreateModel( + name='SecurityEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(choices=[('login_success', 'Successful Login'), ('login_failed', 'Failed Login'), ('password_change', 'Password Changed'), ('account_locked', 'Account Locked'), ('suspicious_activity', 'Suspicious Activity'), ('data_breach', 'Data Breach'), ('unauthorized_access', 'Unauthorized Access Attempt'), ('file_upload', 'File Upload'), ('data_export', 'Data Export'), ('admin_action', 'Admin Action')], max_length=50)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('details', models.JSONField(blank=True, default=dict, help_text='Additional event details')), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='low', max_length=20)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('resolved', models.BooleanField(default=False)), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('resolved_by', models.ForeignKey(blank=True, limit_choices_to={'role': 'admin'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_security_events', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='security_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Security Event', + 'verbose_name_plural': 'Security Events', + 'db_table': 'security_securityevent', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['event_type', 'timestamp'], name='security_se_event_t_1a00f0_idx'), models.Index(fields=['severity', 'timestamp'], name='security_se_severit_5c25b4_idx'), models.Index(fields=['user', 'timestamp'], name='security_se_user_id_6ceb62_idx'), models.Index(fields=['resolved', 'timestamp'], name='security_se_resolve_dbd0de_idx')], + }, + ), + ] diff --git a/legal/migrations/__init__.py b/legal/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legal/models.py b/legal/models.py new file mode 100644 index 0000000..dfb7c02 --- /dev/null +++ b/legal/models.py @@ -0,0 +1,210 @@ +""" +Legal compliance and GDPR models. +""" +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone + +User = get_user_model() + + +class ConsentRecord(models.Model): + """ + Track user consent for GDPR compliance. + """ + CONSENT_TYPE_CHOICES = [ + ('privacy_policy', 'Privacy Policy'), + ('terms_of_service', 'Terms of Service'), + ('data_processing', 'Data Processing'), + ('marketing', 'Marketing Communications'), + ('cookies', 'Cookie Consent'), + ] + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='consents', + null=True, + blank=True + ) + consent_type = models.CharField( + max_length=50, + choices=CONSENT_TYPE_CHOICES + ) + consent_given = models.BooleanField(default=False) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + version = models.CharField( + max_length=20, + blank=True, + help_text='Version of the policy/terms' + ) + + class Meta: + db_table = 'legal_consentrecord' + verbose_name = 'Consent Record' + verbose_name_plural = 'Consent Records' + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['user', 'consent_type']), + models.Index(fields=['consent_type', 'timestamp']), + ] + + def __str__(self): + status = "Given" if self.consent_given else "Not Given" + return f"{self.get_consent_type_display()} - {status} - {self.timestamp}" + + +class DataRequest(models.Model): + """ + GDPR data subject requests (access, deletion, portability). + """ + REQUEST_TYPE_CHOICES = [ + ('access', 'Data Access Request'), + ('deletion', 'Data Deletion Request'), + ('portability', 'Data Portability Request'), + ('rectification', 'Data Rectification Request'), + ('objection', 'Objection to Processing'), + ('restriction', 'Restriction of Processing'), + ] + + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ('rejected', 'Rejected'), + ] + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='data_requests' + ) + request_type = models.CharField( + max_length=50, + choices=REQUEST_TYPE_CHOICES + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending' + ) + description = models.TextField( + blank=True, + help_text='Additional details about the request' + ) + requested_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + response_data = models.JSONField( + default=dict, + blank=True, + help_text='Response data (e.g., exported data)' + ) + response_file = models.FileField( + upload_to='data_requests/', + blank=True, + null=True, + help_text='File containing requested data' + ) + handled_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='handled_data_requests', + limit_choices_to={'role': 'admin'} + ) + notes = models.TextField( + blank=True, + help_text='Internal notes about handling the request' + ) + + class Meta: + db_table = 'legal_datarequest' + verbose_name = 'Data Request' + verbose_name_plural = 'Data Requests' + ordering = ['-requested_at'] + indexes = [ + models.Index(fields=['user', 'status']), + models.Index(fields=['request_type', 'status']), + models.Index(fields=['status', 'requested_at']), + ] + + def __str__(self): + return f"{self.get_request_type_display()} by {self.user.username} - {self.get_status_display()}" + + +class SecurityEvent(models.Model): + """ + Security event logging for compliance and monitoring. + """ + EVENT_TYPE_CHOICES = [ + ('login_success', 'Successful Login'), + ('login_failed', 'Failed Login'), + ('password_change', 'Password Changed'), + ('account_locked', 'Account Locked'), + ('suspicious_activity', 'Suspicious Activity'), + ('data_breach', 'Data Breach'), + ('unauthorized_access', 'Unauthorized Access Attempt'), + ('file_upload', 'File Upload'), + ('data_export', 'Data Export'), + ('admin_action', 'Admin Action'), + ] + + SEVERITY_CHOICES = [ + ('low', 'Low'), + ('medium', 'Medium'), + ('high', 'High'), + ('critical', 'Critical'), + ] + + event_type = models.CharField( + max_length=50, + choices=EVENT_TYPE_CHOICES + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='security_events' + ) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True) + details = models.JSONField( + default=dict, + blank=True, + help_text='Additional event details' + ) + severity = models.CharField( + max_length=20, + choices=SEVERITY_CHOICES, + default='low' + ) + timestamp = models.DateTimeField(auto_now_add=True) + resolved = models.BooleanField(default=False) + resolved_at = models.DateTimeField(null=True, blank=True) + resolved_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='resolved_security_events', + limit_choices_to={'role': 'admin'} + ) + + class Meta: + db_table = 'security_securityevent' + verbose_name = 'Security Event' + verbose_name_plural = 'Security Events' + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['event_type', 'timestamp']), + models.Index(fields=['severity', 'timestamp']), + models.Index(fields=['user', 'timestamp']), + models.Index(fields=['resolved', 'timestamp']), + ] + + def __str__(self): + return f"{self.get_event_type_display()} - {self.get_severity_display()} - {self.timestamp}" diff --git a/legal/tests.py b/legal/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/legal/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/legal/urls.py b/legal/urls.py new file mode 100644 index 0000000..77f3347 --- /dev/null +++ b/legal/urls.py @@ -0,0 +1,16 @@ +""" +URL configuration for legal app. +""" +from django.urls import path +from . import views + +app_name = 'legal' + +urlpatterns = [ + path('privacy/', views.PrivacyPolicyView.as_view(), name='privacy'), + path('terms/', views.TermsOfServiceView.as_view(), name='terms'), + path('data-request/', views.DataRequestView.as_view(), name='data_request'), + path('data-request//', views.DataRequestDetailView.as_view(), name='data_request_detail'), + path('cookie-consent/', views.cookie_consent_view, name='cookie_consent'), +] + diff --git a/legal/views.py b/legal/views.py new file mode 100644 index 0000000..5e18133 --- /dev/null +++ b/legal/views.py @@ -0,0 +1,111 @@ +""" +Views for legal app. +""" +from django.shortcuts import render +from django.views.generic import TemplateView, CreateView, DetailView +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse_lazy +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from .models import DataRequest, ConsentRecord +from .forms import DataRequestForm + + +class PrivacyPolicyView(TemplateView): + """Privacy policy page.""" + template_name = 'legal/privacy_policy.html' + + +class TermsOfServiceView(TemplateView): + """Terms of service page.""" + template_name = 'legal/terms_of_service.html' + + +class DataRequestView(LoginRequiredMixin, CreateView): + """GDPR data request form.""" + model = DataRequest + form_class = DataRequestForm + template_name = 'legal/data_request.html' + success_url = reverse_lazy('legal:data_request_detail') + + def form_valid(self, form): + form.instance.user = self.request.user + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('legal:data_request_detail', kwargs={'pk': self.object.pk}) + + +class DataRequestDetailView(LoginRequiredMixin, DetailView): + """View data request status.""" + model = DataRequest + template_name = 'legal/data_request_detail.html' + context_object_name = 'data_request' + + def get_queryset(self): + return DataRequest.objects.filter(user=self.request.user) + + +@require_http_methods(["POST"]) +def cookie_consent_view(request): + """ + Handle cookie consent submission. + Stores consent in database and sets a cookie. + """ + import json + from django.utils import timezone + + try: + data = json.loads(request.body) + consent_given = data.get('consent', False) + + # Get client IP + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip_address = x_forwarded_for.split(',')[0] + else: + ip_address = request.META.get('REMOTE_ADDR') + + # Create consent record + ConsentRecord.objects.create( + user=request.user if request.user.is_authenticated else None, + consent_type='cookies', + consent_given=consent_given, + ip_address=ip_address, + user_agent=request.META.get('HTTP_USER_AGENT', ''), + version='1.0' + ) + + # Create response + response = JsonResponse({ + 'success': True, + 'message': 'Cookie consent recorded successfully' + }) + + # Set cookie (expires in 1 year) + if consent_given: + response.set_cookie( + 'cookie_consent', + 'accepted', + max_age=31536000, # 1 year in seconds + httponly=False, + samesite='Lax', + secure=request.is_secure() + ) + else: + response.set_cookie( + 'cookie_consent', + 'declined', + max_age=31536000, + httponly=False, + samesite='Lax', + secure=request.is_secure() + ) + + return response + + except Exception as e: + return JsonResponse({ + 'success': False, + 'message': str(e) + }, status=400) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..6b589c8 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fraud_platform.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/moderation/__init__.py b/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/moderation/admin.py b/moderation/admin.py new file mode 100644 index 0000000..32760c1 --- /dev/null +++ b/moderation/admin.py @@ -0,0 +1,32 @@ +""" +Admin configuration for moderation app. +""" +from django.contrib import admin +from .models import ModerationQueue, ModerationAction, ModerationRule + + +@admin.register(ModerationQueue) +class ModerationQueueAdmin(admin.ModelAdmin): + """Moderation queue admin.""" + list_display = ('report', 'priority', 'assigned_to', 'created_at') + list_filter = ('priority', 'created_at') + search_fields = ('report__title',) + date_hierarchy = 'created_at' + + +@admin.register(ModerationAction) +class ModerationActionAdmin(admin.ModelAdmin): + """Moderation action admin.""" + list_display = ('report', 'moderator', 'action_type', 'created_at') + list_filter = ('action_type', 'created_at') + search_fields = ('report__title', 'moderator__username', 'reason') + readonly_fields = ('created_at',) + date_hierarchy = 'created_at' + + +@admin.register(ModerationRule) +class ModerationRuleAdmin(admin.ModelAdmin): + """Moderation rule admin.""" + list_display = ('name', 'is_active', 'priority', 'updated_at') + list_filter = ('is_active',) + search_fields = ('name', 'description') diff --git a/moderation/apps.py b/moderation/apps.py new file mode 100644 index 0000000..5802ddd --- /dev/null +++ b/moderation/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModerationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'moderation' diff --git a/moderation/migrations/0001_initial.py b/moderation/migrations/0001_initial.py new file mode 100644 index 0000000..8f445b4 --- /dev/null +++ b/moderation/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2.8 on 2025-11-26 13:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('reports', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ModerationRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('priority', models.IntegerField(default=0, help_text='Rule priority (higher = evaluated first)')), + ('conditions', models.JSONField(default=dict, help_text='Conditions that trigger this rule')), + ('actions', models.JSONField(default=dict, help_text='Actions to take when rule matches')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Moderation Rule', + 'verbose_name_plural': 'Moderation Rules', + 'db_table': 'moderation_moderationrule', + 'ordering': ['-priority', 'name'], + }, + ), + migrations.CreateModel( + name='ModerationAction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action_type', models.CharField(choices=[('approve', 'Approve'), ('reject', 'Reject'), ('edit', 'Edit'), ('delete', 'Delete'), ('verify', 'Verify'), ('archive', 'Archive'), ('unarchive', 'Unarchive'), ('assign', 'Assign'), ('unassign', 'Unassign')], max_length=20)), + ('reason', models.CharField(blank=True, help_text='Reason for the action', max_length=200)), + ('notes', models.TextField(blank=True, help_text='Additional notes')), + ('previous_status', models.CharField(blank=True, max_length=20)), + ('new_status', models.CharField(blank=True, max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('moderator', models.ForeignKey(limit_choices_to={'role__in': ['moderator', 'admin']}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moderation_actions', to=settings.AUTH_USER_MODEL)), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_actions', to='reports.scamreport')), + ], + options={ + 'verbose_name': 'Moderation Action', + 'verbose_name_plural': 'Moderation Actions', + 'db_table': 'moderation_moderationaction', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['report', 'created_at'], name='moderation__report__971308_idx'), models.Index(fields=['moderator', 'created_at'], name='moderation__moderat_b59e8d_idx'), models.Index(fields=['action_type', 'created_at'], name='moderation__action__8d1226_idx')], + }, + ), + migrations.CreateModel( + name='ModerationQueue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('urgent', 'Urgent')], default='normal', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('assigned_to', models.ForeignKey(blank=True, limit_choices_to={'role__in': ['moderator', 'admin']}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_moderations', to=settings.AUTH_USER_MODEL)), + ('report', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_queue', to='reports.scamreport')), + ], + options={ + 'verbose_name': 'Moderation Queue', + 'verbose_name_plural': 'Moderation Queues', + 'db_table': 'moderation_moderationqueue', + 'ordering': ['-priority', 'created_at'], + 'indexes': [models.Index(fields=['priority', 'created_at'], name='moderation__priorit_02ba25_idx'), models.Index(fields=['assigned_to', 'created_at'], name='moderation__assigne_674975_idx')], + }, + ), + ] diff --git a/moderation/migrations/0002_alter_moderationaction_reason.py b/moderation/migrations/0002_alter_moderationaction_reason.py new file mode 100644 index 0000000..c8fef47 --- /dev/null +++ b/moderation/migrations/0002_alter_moderationaction_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-11-26 14:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('moderation', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='moderationaction', + name='reason', + field=models.TextField(blank=True, help_text='Reason for the action (visible to user for rejections)'), + ), + ] diff --git a/moderation/migrations/__init__.py b/moderation/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/moderation/models.py b/moderation/models.py new file mode 100644 index 0000000..3660a6d --- /dev/null +++ b/moderation/models.py @@ -0,0 +1,145 @@ +""" +Moderation system models. +""" +from django.db import models +from django.contrib.auth import get_user_model +from reports.models import ScamReport + +User = get_user_model() + + +class ModerationQueue(models.Model): + """ + Queue for reports awaiting moderation. + """ + PRIORITY_CHOICES = [ + ('low', 'Low'), + ('normal', 'Normal'), + ('high', 'High'), + ('urgent', 'Urgent'), + ] + + report = models.OneToOneField( + ScamReport, + on_delete=models.CASCADE, + related_name='moderation_queue' + ) + priority = models.CharField( + max_length=20, + choices=PRIORITY_CHOICES, + default='normal' + ) + assigned_to = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='assigned_moderations', + limit_choices_to={'role__in': ['moderator', 'admin']} + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'moderation_moderationqueue' + verbose_name = 'Moderation Queue' + verbose_name_plural = 'Moderation Queues' + ordering = ['-priority', 'created_at'] + indexes = [ + models.Index(fields=['priority', 'created_at']), + models.Index(fields=['assigned_to', 'created_at']), + ] + + def __str__(self): + return f"Queue entry for Report #{self.report.id} - {self.get_priority_display()}" + + +class ModerationAction(models.Model): + """ + Log of moderation actions taken. + """ + ACTION_TYPE_CHOICES = [ + ('approve', 'Approve'), + ('reject', 'Reject'), + ('edit', 'Edit'), + ('delete', 'Delete'), + ('verify', 'Verify'), + ('archive', 'Archive'), + ('unarchive', 'Unarchive'), + ('assign', 'Assign'), + ('unassign', 'Unassign'), + ] + + report = models.ForeignKey( + ScamReport, + on_delete=models.CASCADE, + related_name='moderation_actions' + ) + moderator = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='moderation_actions', + limit_choices_to={'role__in': ['moderator', 'admin']} + ) + action_type = models.CharField( + max_length=20, + choices=ACTION_TYPE_CHOICES + ) + reason = models.TextField( + blank=True, + help_text='Reason for the action (visible to user for rejections)' + ) + notes = models.TextField( + blank=True, + help_text='Additional notes' + ) + previous_status = models.CharField(max_length=20, blank=True) + new_status = models.CharField(max_length=20, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'moderation_moderationaction' + verbose_name = 'Moderation Action' + verbose_name_plural = 'Moderation Actions' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['report', 'created_at']), + models.Index(fields=['moderator', 'created_at']), + models.Index(fields=['action_type', 'created_at']), + ] + + def __str__(self): + return f"{self.get_action_type_display()} on Report #{self.report.id} by {self.moderator}" + + +class ModerationRule(models.Model): + """ + Automated moderation rules. + """ + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + priority = models.IntegerField( + default=0, + help_text='Rule priority (higher = evaluated first)' + ) + conditions = models.JSONField( + default=dict, + help_text='Conditions that trigger this rule' + ) + actions = models.JSONField( + default=dict, + help_text='Actions to take when rule matches' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'moderation_moderationrule' + verbose_name = 'Moderation Rule' + verbose_name_plural = 'Moderation Rules' + ordering = ['-priority', 'name'] + + def __str__(self): + return f"{self.name} ({'Active' if self.is_active else 'Inactive'})" diff --git a/moderation/tests.py b/moderation/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/moderation/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/moderation/urls.py b/moderation/urls.py new file mode 100644 index 0000000..2b6422c --- /dev/null +++ b/moderation/urls.py @@ -0,0 +1,16 @@ +""" +URL configuration for moderation app. +""" +from django.urls import path +from . import views + +app_name = 'moderation' + +urlpatterns = [ + path('', views.ModerationDashboardView.as_view(), name='dashboard'), + path('queue/', views.ModerationQueueView.as_view(), name='queue'), + path('report//', views.ReportModerationView.as_view(), name='report_detail'), + path('report//approve/', views.ApproveReportView.as_view(), name='approve'), + path('report//reject/', views.RejectReportView.as_view(), name='reject'), +] + diff --git a/moderation/views.py b/moderation/views.py new file mode 100644 index 0000000..8ce498f --- /dev/null +++ b/moderation/views.py @@ -0,0 +1,129 @@ +""" +Views for moderation app. +""" +from django.shortcuts import get_object_or_404, redirect +from django.views.generic import ListView, DetailView, UpdateView +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils import timezone +from reports.models import ScamReport +from .models import ModerationQueue, ModerationAction + + +class ModeratorRequiredMixin(UserPassesTestMixin): + """Mixin to require moderator role.""" + def test_func(self): + return self.request.user.is_authenticated and self.request.user.is_moderator() + + +class ModerationDashboardView(LoginRequiredMixin, ModeratorRequiredMixin, ListView): + """Moderation dashboard.""" + template_name = 'moderation/dashboard.html' + context_object_name = 'reports' + + def get_queryset(self): + return ScamReport.objects.filter( + status__in=['pending', 'under_review'] + ).order_by('-created_at')[:10] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['pending_count'] = ScamReport.objects.filter(status='pending').count() + context['under_review_count'] = ScamReport.objects.filter(status='under_review').count() + context['verified_count'] = ScamReport.objects.filter(status='verified').count() + return context + + +class ModerationQueueView(LoginRequiredMixin, ModeratorRequiredMixin, ListView): + """Moderation queue.""" + model = ModerationQueue + template_name = 'moderation/queue.html' + context_object_name = 'queue_items' + paginate_by = 20 + + def get_queryset(self): + return ModerationQueue.objects.select_related( + 'report', 'assigned_to' + ).order_by('-priority', 'created_at') + + +class ReportModerationView(LoginRequiredMixin, ModeratorRequiredMixin, DetailView): + """View report for moderation.""" + model = ScamReport + template_name = 'moderation/report_detail.html' + context_object_name = 'report' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['osint_results'] = self.object.osint_results.all() + context['verifications'] = self.object.verifications.all() + context['moderation_actions'] = self.object.moderation_actions.all()[:10] + return context + + +class ApproveReportView(LoginRequiredMixin, ModeratorRequiredMixin, SuccessMessageMixin, UpdateView): + """Approve a report.""" + model = ScamReport + fields = [] + template_name = 'moderation/approve.html' + success_message = "Report approved successfully!" + + def form_valid(self, form): + previous_status = form.instance.status + form.instance.status = 'verified' + form.instance.verified_at = timezone.now() + response = super().form_valid(form) + + # Create moderation action + ModerationAction.objects.create( + report=form.instance, + moderator=self.request.user, + action_type='approve', + previous_status=previous_status, + new_status='verified' + ) + + # Remove from queue + ModerationQueue.objects.filter(report=form.instance).delete() + + return response + + def get_success_url(self): + return reverse_lazy('moderation:queue') + + +class RejectReportView(LoginRequiredMixin, ModeratorRequiredMixin, SuccessMessageMixin, UpdateView): + """Reject a report.""" + model = ScamReport + fields = [] + template_name = 'moderation/reject.html' + success_message = "Report rejected." + + def form_valid(self, form): + previous_status = form.instance.status + form.instance.status = 'rejected' + response = super().form_valid(form) + + # Get reason from form + reason = self.request.POST.get('reason', '').strip() + notes = self.request.POST.get('notes', '').strip() + + # Create moderation action + ModerationAction.objects.create( + report=form.instance, + moderator=self.request.user, + action_type='reject', + previous_status=previous_status, + new_status='rejected', + reason=reason, + notes=notes + ) + + # Remove from queue + ModerationQueue.objects.filter(report=form.instance).delete() + + return response + + def get_success_url(self): + return reverse_lazy('moderation:queue') diff --git a/osint/__init__.py b/osint/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/osint/admin.py b/osint/admin.py new file mode 100644 index 0000000..d3c53f6 --- /dev/null +++ b/osint/admin.py @@ -0,0 +1,246 @@ +""" +Admin configuration for osint app. +""" +from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils import timezone +from datetime import timedelta +from .models import ( + OSINTTask, OSINTResult, OSINTConfiguration, + SeedWebsite, OSINTKeyword, CrawledContent, AutoGeneratedReport +) + + +@admin.register(OSINTTask) +class OSINTTaskAdmin(admin.ModelAdmin): + """OSINT task admin.""" + list_display = ('report', 'task_type', 'status', 'created_at', 'completed_at') + list_filter = ('task_type', 'status', 'created_at') + search_fields = ('report__title', 'error_message') + readonly_fields = ('created_at', 'started_at', 'completed_at') + date_hierarchy = 'created_at' + + +@admin.register(OSINTResult) +class OSINTResultAdmin(admin.ModelAdmin): + """OSINT result admin.""" + list_display = ('report', 'source', 'data_type', 'confidence_level', 'is_verified', 'collected_at') + list_filter = ('data_type', 'is_verified', 'collected_at') + search_fields = ('report__title', 'source') + readonly_fields = ('collected_at', 'updated_at') + date_hierarchy = 'collected_at' + + +@admin.register(OSINTConfiguration) +class OSINTConfigurationAdmin(admin.ModelAdmin): + """OSINT configuration admin.""" + list_display = ('service_name', 'is_active', 'rate_limit', 'updated_at') + list_filter = ('is_active',) + search_fields = ('service_name',) + + +@admin.register(SeedWebsite) +class SeedWebsiteAdmin(admin.ModelAdmin): + """Seed website admin.""" + list_display = ('name', 'url', 'is_active', 'priority', 'last_crawled_at', 'pages_crawled', 'matches_found', 'status_indicator') + list_filter = ('is_active', 'priority', 'created_at') + search_fields = ('name', 'url', 'description') + readonly_fields = ('last_crawled_at', 'pages_crawled', 'matches_found', 'created_at', 'updated_at') + fieldsets = ( + ('Basic Information', { + 'fields': ('name', 'url', 'description', 'is_active', 'priority', 'created_by') + }), + ('Crawling Configuration', { + 'fields': ('crawl_depth', 'crawl_interval_hours', 'allowed_domains', 'user_agent') + }), + ('Statistics', { + 'fields': ('last_crawled_at', 'pages_crawled', 'matches_found'), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + date_hierarchy = 'created_at' + + def status_indicator(self, obj): + """Show visual status indicator.""" + if not obj.is_active: + return format_html(' Inactive') + if not obj.last_crawled_at: + return format_html(' Never Crawled') + + hours_since = (timezone.now() - obj.last_crawled_at).total_seconds() / 3600 + if hours_since > obj.crawl_interval_hours * 2: + return format_html(' Overdue') + elif hours_since > obj.crawl_interval_hours: + return format_html(' Due Soon') + else: + return format_html(' Up to Date') + status_indicator.short_description = 'Status' + + def save_model(self, request, obj, form, change): + if not change: # New object + obj.created_by = request.user + super().save_model(request, obj, form, change) + + +@admin.register(OSINTKeyword) +class OSINTKeywordAdmin(admin.ModelAdmin): + """OSINT keyword admin.""" + list_display = ('name', 'keyword', 'keyword_type', 'is_active', 'confidence_score', 'auto_approve', 'match_count') + list_filter = ('is_active', 'keyword_type', 'auto_approve', 'created_at') + search_fields = ('name', 'keyword', 'description') + readonly_fields = ('created_at', 'updated_at', 'match_count') + fieldsets = ( + ('Basic Information', { + 'fields': ('name', 'keyword', 'description', 'keyword_type', 'is_active', 'created_by') + }), + ('Matching Configuration', { + 'fields': ('case_sensitive', 'confidence_score', 'auto_approve') + }), + ('Statistics', { + 'fields': ('match_count',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + date_hierarchy = 'created_at' + + def match_count(self, obj): + """Count how many times this keyword has matched.""" + return obj.matched_contents.count() + match_count.short_description = 'Total Matches' + + def save_model(self, request, obj, form, change): + if not change: # New object + obj.created_by = request.user + super().save_model(request, obj, form, change) + + +@admin.register(CrawledContent) +class CrawledContentAdmin(admin.ModelAdmin): + """Crawled content admin.""" + list_display = ('title', 'url', 'seed_website', 'match_count', 'confidence_score', 'has_potential_scam', 'crawled_at') + list_filter = ('has_potential_scam', 'seed_website', 'crawled_at', 'http_status') + search_fields = ('title', 'url', 'content') + readonly_fields = ('crawled_at', 'content_hash', 'http_status') + fieldsets = ( + ('Content Information', { + 'fields': ('seed_website', 'url', 'title', 'content', 'html_content') + }), + ('Analysis', { + 'fields': ('matched_keywords', 'match_count', 'confidence_score', 'has_potential_scam') + }), + ('Metadata', { + 'fields': ('http_status', 'content_hash', 'crawled_at'), + 'classes': ('collapse',) + }), + ) + date_hierarchy = 'crawled_at' + filter_horizontal = ('matched_keywords',) + + def get_queryset(self, request): + return super().get_queryset(request).select_related('seed_website').prefetch_related('matched_keywords') + + +@admin.register(AutoGeneratedReport) +class AutoGeneratedReportAdmin(admin.ModelAdmin): + """Auto-generated report admin.""" + list_display = ('title', 'source_url', 'status', 'confidence_score', 'reviewed_by', 'reviewed_at', 'view_report_link') + list_filter = ('status', 'confidence_score', 'created_at', 'reviewed_at') + search_fields = ('title', 'description', 'source_url') + readonly_fields = ('crawled_content', 'created_at', 'updated_at', 'published_at') + fieldsets = ( + ('Report Information', { + 'fields': ('crawled_content', 'title', 'description', 'source_url') + }), + ('Analysis', { + 'fields': ('matched_keywords', 'confidence_score') + }), + ('Review', { + 'fields': ('status', 'review_notes', 'reviewed_by', 'reviewed_at', 'report') + }), + ('Publication', { + 'fields': ('published_at',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + date_hierarchy = 'created_at' + filter_horizontal = ('matched_keywords',) + actions = ['approve_reports', 'reject_reports', 'publish_reports'] + + def view_report_link(self, obj): + """Link to the generated report if exists.""" + if obj.report: + url = reverse('admin:reports_scamreport_change', args=[obj.report.pk]) + return format_html('View Report #{}', url, obj.report.pk) + return '-' + view_report_link.short_description = 'Linked Report' + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + 'crawled_content', 'reviewed_by', 'report' + ).prefetch_related('matched_keywords') + + @admin.action(description='Approve selected reports') + def approve_reports(self, request, queryset): + """Approve selected auto-generated reports.""" + from django.utils import timezone + updated = queryset.filter(status='pending').update( + status='approved', + reviewed_by=request.user, + reviewed_at=timezone.now() + ) + self.message_user(request, f'{updated} reports approved.') + + @admin.action(description='Reject selected reports') + def reject_reports(self, request, queryset): + """Reject selected auto-generated reports.""" + from django.utils import timezone + updated = queryset.filter(status='pending').update( + status='rejected', + reviewed_by=request.user, + reviewed_at=timezone.now() + ) + self.message_user(request, f'{updated} reports rejected.') + + @admin.action(description='Publish selected reports') + def publish_reports(self, request, queryset): + """Publish approved reports.""" + from django.utils import timezone + from reports.models import ScamReport + from reports.models import ScamTag + + published = 0 + for auto_report in queryset.filter(status='approved'): + if not auto_report.report: + # Create the actual scam report + report = ScamReport.objects.create( + title=auto_report.title, + description=auto_report.description, + reported_url=auto_report.source_url, + scam_type='other', # Default type + status='verified', # Auto-verified since reviewed + verification_score=auto_report.confidence_score, + is_public=True, + is_anonymous=True, # System-generated + is_auto_discovered=True, # Mark as auto-discovered + reporter_ip=None, # System-generated + ) + auto_report.report = report + auto_report.status = 'published' + auto_report.published_at = timezone.now() + auto_report.save() + published += 1 + + self.message_user(request, f'{published} reports published.') diff --git a/osint/apps.py b/osint/apps.py new file mode 100644 index 0000000..f6c09eb --- /dev/null +++ b/osint/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OsintConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'osint' diff --git a/osint/forms.py b/osint/forms.py new file mode 100644 index 0000000..6862692 --- /dev/null +++ b/osint/forms.py @@ -0,0 +1,97 @@ +""" +Forms for OSINT app. +""" +import json +from django import forms +from django.core.exceptions import ValidationError +from .models import SeedWebsite, OSINTKeyword + + +class SeedWebsiteForm(forms.ModelForm): + """Form for creating/editing seed websites.""" + allowed_domains_text = forms.CharField( + required=False, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Enter domains separated by commas or as JSON array, e.g. example.com, subdomain.example.com\nOr: ["example.com", "subdomain.example.com"]' + }), + help_text='Enter domains separated by commas or as JSON array. Leave empty for same domain only.' + ) + + class Meta: + model = SeedWebsite + fields = [ + 'name', 'url', 'description', 'is_active', 'priority', + 'crawl_depth', 'crawl_interval_hours', 'user_agent' + ] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'url': forms.URLInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'priority': forms.Select(attrs={'class': 'form-control'}), + 'crawl_depth': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 5}), + 'crawl_interval_hours': forms.NumberInput(attrs={'class': 'form-control', 'min': 1}), + 'user_agent': forms.TextInput(attrs={'class': 'form-control'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk and self.instance.allowed_domains: + # Convert list to text representation + if isinstance(self.instance.allowed_domains, list): + self.fields['allowed_domains_text'].initial = ', '.join(self.instance.allowed_domains) + else: + self.fields['allowed_domains_text'].initial = str(self.instance.allowed_domains) + + def clean_allowed_domains_text(self): + text = self.cleaned_data.get('allowed_domains_text', '').strip() + if not text: + return [] + + # Try to parse as JSON first + try: + domains = json.loads(text) + if isinstance(domains, list): + return [str(d).strip() for d in domains if d] + except (json.JSONDecodeError, ValueError): + pass + + # Otherwise, treat as comma-separated + domains = [d.strip() for d in text.split(',') if d.strip()] + return domains + + def save(self, commit=True): + instance = super().save(commit=False) + instance.allowed_domains = self.cleaned_data.get('allowed_domains_text', []) + if commit: + instance.save() + return instance + + +class OSINTKeywordForm(forms.ModelForm): + """Form for creating/editing OSINT keywords.""" + + class Meta: + model = OSINTKeyword + fields = [ + 'name', 'keyword', 'description', 'keyword_type', 'is_active', + 'case_sensitive', 'confidence_score', 'auto_approve' + ] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'keyword': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + 'keyword_type': forms.Select(attrs={'class': 'form-control'}), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'case_sensitive': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'confidence_score': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 0, + 'max': 100, + 'step': 1 + }), + 'auto_approve': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + diff --git a/osint/management/__init__.py b/osint/management/__init__.py new file mode 100644 index 0000000..5bd381b --- /dev/null +++ b/osint/management/__init__.py @@ -0,0 +1,2 @@ +# Management package + diff --git a/osint/management/commands/__init__.py b/osint/management/commands/__init__.py new file mode 100644 index 0000000..90764a9 --- /dev/null +++ b/osint/management/commands/__init__.py @@ -0,0 +1,2 @@ +# Management commands package + diff --git a/osint/management/commands/crawl_osint.py b/osint/management/commands/crawl_osint.py new file mode 100644 index 0000000..1fd745d --- /dev/null +++ b/osint/management/commands/crawl_osint.py @@ -0,0 +1,360 @@ +""" +Management command for OSINT crawling from seed websites. +""" +import re +import hashlib +import time +from urllib.parse import urljoin, urlparse +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.db import transaction, models +from django.conf import settings +import requests +from bs4 import BeautifulSoup +from osint.models import SeedWebsite, OSINTKeyword, CrawledContent, AutoGeneratedReport +from reports.models import ScamReport + + +class Command(BaseCommand): + help = 'Crawl seed websites and search for scam-related keywords' + + def add_arguments(self, parser): + parser.add_argument( + '--seed-id', + type=int, + help='Crawl specific seed website by ID', + ) + parser.add_argument( + '--all', + action='store_true', + help='Crawl all active seed websites', + ) + parser.add_argument( + '--force', + action='store_true', + help='Force crawl even if recently crawled', + ) + parser.add_argument( + '--max-pages', + type=int, + default=50, + help='Maximum pages to crawl per seed website (default: 50)', + ) + parser.add_argument( + '--delay', + type=float, + default=1.0, + help='Delay between requests in seconds (default: 1.0)', + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('Starting OSINT crawling...')) + + # Get seed websites to crawl + if options['seed_id']: + seeds = SeedWebsite.objects.filter(id=options['seed_id'], is_active=True) + elif options['all']: + seeds = SeedWebsite.objects.filter(is_active=True) + else: + # Default: crawl websites that are due + now = timezone.now() + seeds = SeedWebsite.objects.filter( + is_active=True + ).filter( + models.Q(last_crawled_at__isnull=True) | + models.Q(last_crawled_at__lt=now - timezone.timedelta(hours=models.F('crawl_interval_hours'))) + ) + + if not seeds.exists(): + self.stdout.write(self.style.WARNING('No seed websites to crawl.')) + return + + # Get active keywords + keywords = OSINTKeyword.objects.filter(is_active=True) + if not keywords.exists(): + self.stdout.write(self.style.WARNING('No active keywords configured.')) + return + + self.stdout.write(f'Found {seeds.count()} seed website(s) to crawl') + self.stdout.write(f'Found {keywords.count()} active keyword(s)') + + total_pages = 0 + total_matches = 0 + + for seed in seeds: + self.stdout.write(f'\nCrawling: {seed.name} ({seed.url})') + pages, matches = self.crawl_seed(seed, keywords, options) + total_pages += pages + total_matches += matches + + # Update seed website stats + seed.last_crawled_at = timezone.now() + seed.pages_crawled += pages + seed.matches_found += matches + seed.save() + + self.stdout.write(self.style.SUCCESS( + f'\nCrawling completed! Total pages: {total_pages}, Total matches: {total_matches}' + )) + + def crawl_seed(self, seed, keywords, options): + """Crawl a single seed website.""" + max_pages = options['max_pages'] + delay = options['delay'] + pages_crawled = 0 + matches_found = 0 + + # Parse base URL + parsed_base = urlparse(seed.url) + base_domain = f"{parsed_base.scheme}://{parsed_base.netloc}" + + # Determine allowed domains + allowed_domains = seed.allowed_domains if seed.allowed_domains else [parsed_base.netloc] + + # URLs to visit + visited_urls = set() + urls_to_visit = [(seed.url, 0)] # (url, depth) + + session = requests.Session() + session.headers.update({ + 'User-Agent': seed.user_agent or 'Mozilla/5.0 (compatible; OSINTBot/1.0)' + }) + + while urls_to_visit and pages_crawled < max_pages: + url, depth = urls_to_visit.pop(0) + + # Skip if already visited or too deep + if url in visited_urls or depth > seed.crawl_depth: + continue + + # Check domain + parsed = urlparse(url) + if parsed.netloc not in allowed_domains: + continue + + visited_urls.add(url) + + try: + # Fetch page + self.stdout.write(f' Fetching: {url} (depth: {depth})') + response = session.get(url, timeout=10, allow_redirects=True) + response.raise_for_status() + + # Parse content + soup = BeautifulSoup(response.text, 'lxml') + + # Extract text content + # Remove script and style elements + for script in soup(["script", "style", "meta", "link"]): + script.decompose() + + text_content = soup.get_text(separator=' ', strip=True) + title = soup.title.string if soup.title else '' + html_content = str(soup) + + # Calculate content hash + content_hash = hashlib.sha256(text_content.encode('utf-8')).hexdigest() + + # Check for duplicates + if CrawledContent.objects.filter(url=url, content_hash=content_hash).exists(): + self.stdout.write(f' Skipping duplicate content') + continue + + # Match keywords + matched_keywords = [] + match_count = 0 + + for keyword_obj in keywords: + matches = self.match_keyword(keyword_obj, text_content, url, title) + if matches: + matched_keywords.append(keyword_obj) + match_count += len(matches) + + # Calculate confidence score + confidence_score = self.calculate_confidence(matched_keywords, match_count) + has_potential_scam = confidence_score >= 30 # Threshold + + # Save crawled content + with transaction.atomic(): + crawled_content = CrawledContent.objects.create( + seed_website=seed, + url=url, + title=title[:500], + content=text_content[:10000], # Limit content size + html_content=html_content[:50000], # Limit HTML size + match_count=match_count, + confidence_score=confidence_score, + has_potential_scam=has_potential_scam, + http_status=response.status_code, + content_hash=content_hash + ) + crawled_content.matched_keywords.set(matched_keywords) + + pages_crawled += 1 + + if has_potential_scam: + matches_found += 1 + self.stdout.write(self.style.WARNING( + f' ⚠ Potential scam detected! Confidence: {confidence_score}%' + )) + + # Create auto-generated report + self.create_auto_report(crawled_content, matched_keywords, confidence_score) + + # Extract links for further crawling + if depth < seed.crawl_depth: + for link in soup.find_all('a', href=True): + href = link['href'] + absolute_url = urljoin(url, href) + parsed_link = urlparse(absolute_url) + + # Only follow same-domain links + if parsed_link.netloc in allowed_domains: + if absolute_url not in visited_urls: + urls_to_visit.append((absolute_url, depth + 1)) + + # Rate limiting + time.sleep(delay) + + except requests.RequestException as e: + self.stdout.write(self.style.ERROR(f' Error fetching {url}: {e}')) + continue + except Exception as e: + self.stdout.write(self.style.ERROR(f' Error processing {url}: {e}')) + continue + + return pages_crawled, matches_found + + def match_keyword(self, keyword_obj, text, url, title): + """Match a keyword against text content.""" + keyword = keyword_obj.keyword + flags = 0 if keyword_obj.case_sensitive else re.IGNORECASE + + matches = [] + + if keyword_obj.keyword_type == 'exact': + if keyword_obj.case_sensitive: + if keyword in text or keyword in url or keyword in title: + matches.append(keyword) + else: + if keyword.lower() in text.lower() or keyword.lower() in url.lower() or keyword.lower() in title.lower(): + matches.append(keyword) + + elif keyword_obj.keyword_type == 'regex': + try: + pattern = re.compile(keyword, flags) + matches = pattern.findall(text + ' ' + url + ' ' + title) + except re.error: + self.stdout.write(self.style.ERROR(f' Invalid regex: {keyword}')) + + elif keyword_obj.keyword_type == 'phrase': + # Phrase matching (word boundaries) + pattern = re.compile(r'\b' + re.escape(keyword) + r'\b', flags) + matches = pattern.findall(text + ' ' + url + ' ' + title) + + elif keyword_obj.keyword_type == 'domain': + # Domain pattern matching + pattern = re.compile(keyword, flags) + matches = pattern.findall(url) + + elif keyword_obj.keyword_type == 'email': + # Email pattern + email_pattern = re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', flags) + found_emails = email_pattern.findall(text + ' ' + url) + # Check if any email matches the keyword pattern + pattern = re.compile(keyword, flags) + matches = [email for email in found_emails if pattern.search(email)] + + elif keyword_obj.keyword_type == 'phone': + # Phone pattern + phone_pattern = re.compile(r'[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}', flags) + found_phones = phone_pattern.findall(text) + # Check if any phone matches the keyword pattern + pattern = re.compile(keyword, flags) + matches = [phone for phone in found_phones if pattern.search(phone)] + + return matches + + def calculate_confidence(self, matched_keywords, match_count): + """Calculate confidence score based on matched keywords.""" + if not matched_keywords: + return 0 + + # Base score from keyword confidence scores + base_score = sum(kw.confidence_score for kw in matched_keywords) / len(matched_keywords) + + # Boost for multiple matches + match_boost = min(match_count * 2, 30) # Max 30 point boost + + # Boost for multiple different keywords + keyword_boost = min(len(matched_keywords) * 5, 20) # Max 20 point boost + + total_score = base_score + match_boost + keyword_boost + return min(int(total_score), 100) # Cap at 100 + + def create_auto_report(self, crawled_content, matched_keywords, confidence_score): + """Create an auto-generated report from crawled content.""" + # Check if report already exists + if AutoGeneratedReport.objects.filter(crawled_content=crawled_content).exists(): + return + + # Generate title + title = f"Potential Scam Detected: {crawled_content.title or crawled_content.url}" + if len(title) > 500: + title = title[:497] + '...' + + # Generate description + description = f"Automatically detected potential scam from OSINT crawling.\n\n" + description += f"Source URL: {crawled_content.url}\n" + description += f"Matched Keywords: {', '.join(kw.name for kw in matched_keywords)}\n" + description += f"Confidence Score: {confidence_score}%\n\n" + + # Extract relevant snippet + content_preview = crawled_content.content[:500] + '...' if len(crawled_content.content) > 500 else crawled_content.content + description += f"Content Preview:\n{content_preview}" + + # Determine if should auto-approve + status = 'pending' + if confidence_score >= 80 and any(kw.auto_approve for kw in matched_keywords): + status = 'approved' + + # Create auto-generated report + auto_report = AutoGeneratedReport.objects.create( + crawled_content=crawled_content, + title=title, + description=description, + source_url=crawled_content.url, + confidence_score=confidence_score, + status=status + ) + auto_report.matched_keywords.set(matched_keywords) + + # If auto-approved, create the actual report + if status == 'approved': + self.create_scam_report(auto_report) + + def create_scam_report(self, auto_report): + """Create actual scam report from auto-generated report.""" + from reports.models import ScamReport + + report = ScamReport.objects.create( + title=auto_report.title, + description=auto_report.description, + reported_url=auto_report.source_url, + scam_type='other', # Default type, can be updated by moderator + status='verified', # Auto-verified since reviewed + verification_score=auto_report.confidence_score, + is_public=True, + is_anonymous=True, # System-generated + is_auto_discovered=True, # Mark as auto-discovered + ) + + auto_report.report = report + auto_report.status = 'published' + auto_report.published_at = timezone.now() + auto_report.save() + + self.stdout.write(self.style.SUCCESS( + f' ✓ Auto-approved and published report: {report.title}' + )) + diff --git a/osint/migrations/0001_initial.py b/osint/migrations/0001_initial.py new file mode 100644 index 0000000..e076036 --- /dev/null +++ b/osint/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 5.2.8 on 2025-11-26 13:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('reports', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='OSINTConfiguration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('service_name', models.CharField(max_length=100, unique=True)), + ('api_key', models.CharField(blank=True, help_text='Encrypted API key', max_length=255)), + ('api_url', models.URLField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('rate_limit', models.IntegerField(default=100, help_text='Requests per hour')), + ('configuration', models.JSONField(blank=True, default=dict, help_text='Additional configuration')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'OSINT Configuration', + 'verbose_name_plural': 'OSINT Configurations', + 'db_table': 'osint_osintconfiguration', + }, + ), + migrations.CreateModel( + name='OSINTResult', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source', models.CharField(help_text='OSINT source/service name', max_length=100)), + ('data_type', models.CharField(choices=[('whois', 'WHOIS Data'), ('dns', 'DNS Records'), ('ssl', 'SSL Certificate'), ('archive', 'Archive Data'), ('email', 'Email Data'), ('phone', 'Phone Data'), ('business', 'Business Registry Data'), ('social', 'Social Media Data'), ('reputation', 'Reputation Data')], max_length=50)), + ('raw_data', models.JSONField(default=dict, help_text='Raw data from OSINT source')), + ('processed_data', models.JSONField(blank=True, default=dict, help_text='Processed/cleaned data')), + ('confidence_level', models.IntegerField(default=0, help_text='Confidence level (0-100)')), + ('is_verified', models.BooleanField(default=False, help_text='Manually verified by moderator')), + ('collected_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='osint_results', to='reports.scamreport')), + ], + options={ + 'verbose_name': 'OSINT Result', + 'verbose_name_plural': 'OSINT Results', + 'db_table': 'osint_osintresult', + 'ordering': ['-collected_at'], + 'indexes': [models.Index(fields=['report', 'data_type'], name='osint_osint_report__4a95b0_idx'), models.Index(fields=['confidence_level', 'is_verified'], name='osint_osint_confide_47552d_idx')], + }, + ), + migrations.CreateModel( + name='OSINTTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_type', models.CharField(choices=[('domain_analysis', 'Domain Analysis'), ('url_analysis', 'URL Analysis'), ('email_analysis', 'Email Analysis'), ('phone_analysis', 'Phone Analysis'), ('whois_lookup', 'WHOIS Lookup'), ('dns_lookup', 'DNS Lookup'), ('ssl_check', 'SSL Certificate Check'), ('archive_check', 'Archive Check'), ('business_registry', 'Business Registry Check'), ('social_media', 'Social Media Check')], max_length=50)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), + ('parameters', models.JSONField(default=dict, help_text='Task parameters (e.g., URL, email, phone)')), + ('result', models.JSONField(blank=True, default=dict, help_text='Task result data')), + ('error_message', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('retry_count', models.IntegerField(default=0)), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='osint_tasks', to='reports.scamreport')), + ], + options={ + 'verbose_name': 'OSINT Task', + 'verbose_name_plural': 'OSINT Tasks', + 'db_table': 'osint_osinttask', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['status', 'created_at'], name='osint_osint_status_290802_idx'), models.Index(fields=['report', 'task_type'], name='osint_osint_report__e7bd16_idx')], + }, + ), + ] diff --git a/osint/migrations/0002_osintkeyword_crawledcontent_autogeneratedreport_and_more.py b/osint/migrations/0002_osintkeyword_crawledcontent_autogeneratedreport_and_more.py new file mode 100644 index 0000000..82e9a71 --- /dev/null +++ b/osint/migrations/0002_osintkeyword_crawledcontent_autogeneratedreport_and_more.py @@ -0,0 +1,157 @@ +# Generated by Django 5.2.8 on 2025-11-26 18:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osint', '0001_initial'), + ('reports', '0002_scamreport_is_auto_discovered'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OSINTKeyword', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('keyword', models.CharField(help_text='Keyword, phrase, or regex pattern to search for', max_length=500)), + ('name', models.CharField(help_text='Friendly name for this keyword', max_length=200)), + ('description', models.TextField(blank=True, help_text='Description of what this keyword detects')), + ('keyword_type', models.CharField(choices=[('exact', 'Exact Match'), ('regex', 'Regular Expression'), ('phrase', 'Phrase Match'), ('domain', 'Domain Pattern'), ('email', 'Email Pattern'), ('phone', 'Phone Pattern')], default='phrase', help_text='Type of matching to perform', max_length=20)), + ('is_active', models.BooleanField(default=True, help_text='Enable/disable this keyword')), + ('case_sensitive', models.BooleanField(default=False, help_text='Case sensitive matching')), + ('confidence_score', models.IntegerField(default=50, help_text='Default confidence score (0-100) when this keyword matches')), + ('auto_approve', models.BooleanField(default=False, help_text='Auto-approve reports matching this keyword (requires high confidence)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_keywords', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'OSINT Keyword', + 'verbose_name_plural': 'OSINT Keywords', + 'db_table': 'osint_keyword', + 'ordering': ['-is_active', 'name'], + }, + ), + migrations.CreateModel( + name='CrawledContent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(help_text='URL of the crawled page', max_length=1000)), + ('title', models.CharField(blank=True, help_text='Page title', max_length=500)), + ('content', models.TextField(help_text='Crawled page content')), + ('html_content', models.TextField(blank=True, help_text='Raw HTML content')), + ('match_count', models.IntegerField(default=0, help_text='Number of keyword matches found')), + ('confidence_score', models.IntegerField(default=0, help_text='Calculated confidence score based on matches')), + ('has_potential_scam', models.BooleanField(default=False, help_text='Flagged as potential scam based on keyword matches')), + ('crawled_at', models.DateTimeField(auto_now_add=True)), + ('http_status', models.IntegerField(blank=True, help_text='HTTP status code', null=True)), + ('content_hash', models.CharField(blank=True, help_text='SHA256 hash of content for deduplication', max_length=64)), + ('matched_keywords', models.ManyToManyField(blank=True, help_text='Keywords that matched this content', related_name='matched_contents', to='osint.osintkeyword')), + ], + options={ + 'verbose_name': 'Crawled Content', + 'verbose_name_plural': 'Crawled Contents', + 'db_table': 'osint_crawledcontent', + 'ordering': ['-crawled_at', '-confidence_score'], + }, + ), + migrations.CreateModel( + name='AutoGeneratedReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='Auto-generated report title', max_length=500)), + ('description', models.TextField(help_text='Auto-generated report description')), + ('source_url', models.URLField(help_text='Source URL where scam was found', max_length=1000)), + ('confidence_score', models.IntegerField(default=0, help_text='Confidence score (0-100)')), + ('status', models.CharField(choices=[('pending', 'Pending Review'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('published', 'Published')], default='pending', help_text='Review status', max_length=20)), + ('review_notes', models.TextField(blank=True, help_text='Notes from moderator/admin review')), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('report', models.ForeignKey(blank=True, help_text='Linked scam report (created when approved)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_generated_reports', to='reports.scamreport')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_auto_reports', to=settings.AUTH_USER_MODEL)), + ('crawled_content', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='auto_report', to='osint.crawledcontent')), + ('matched_keywords', models.ManyToManyField(related_name='generated_reports', to='osint.osintkeyword')), + ], + options={ + 'verbose_name': 'Auto-Generated Report', + 'verbose_name_plural': 'Auto-Generated Reports', + 'db_table': 'osint_autogeneratedreport', + 'ordering': ['-created_at', '-confidence_score'], + }, + ), + migrations.CreateModel( + name='SeedWebsite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(help_text='Base URL to crawl', max_length=500)), + ('name', models.CharField(help_text='Friendly name for this seed website', max_length=200)), + ('description', models.TextField(blank=True, help_text='Description of the website')), + ('is_active', models.BooleanField(default=True, help_text='Enable/disable crawling for this website')), + ('priority', models.CharField(choices=[('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], default='medium', help_text='Crawling priority', max_length=10)), + ('crawl_depth', models.IntegerField(default=2, help_text='Maximum depth to crawl (0 = only this page, 1 = this page + direct links, etc.)')), + ('crawl_interval_hours', models.IntegerField(default=24, help_text='Hours between crawls')), + ('allowed_domains', models.JSONField(blank=True, default=list, help_text='List of allowed domains to crawl (empty = same domain only)')), + ('user_agent', models.CharField(blank=True, default='Mozilla/5.0 (compatible; OSINTBot/1.0)', help_text='User agent string for requests', max_length=255)), + ('last_crawled_at', models.DateTimeField(blank=True, null=True)), + ('pages_crawled', models.IntegerField(default=0)), + ('matches_found', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_seed_websites', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Seed Website', + 'verbose_name_plural': 'Seed Websites', + 'db_table': 'osint_seedwebsite', + 'ordering': ['-priority', '-last_crawled_at'], + }, + ), + migrations.AddField( + model_name='crawledcontent', + name='seed_website', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='crawled_contents', to='osint.seedwebsite'), + ), + migrations.AddIndex( + model_name='osintkeyword', + index=models.Index(fields=['is_active', 'keyword_type'], name='osint_keywo_is_acti_6f4814_idx'), + ), + migrations.AddIndex( + model_name='autogeneratedreport', + index=models.Index(fields=['status', 'confidence_score'], name='osint_autog_status_a8a215_idx'), + ), + migrations.AddIndex( + model_name='autogeneratedreport', + index=models.Index(fields=['created_at'], name='osint_autog_created_07e2b0_idx'), + ), + migrations.AddIndex( + model_name='seedwebsite', + index=models.Index(fields=['is_active', 'priority'], name='osint_seedw_is_acti_411fa2_idx'), + ), + migrations.AddIndex( + model_name='seedwebsite', + index=models.Index(fields=['last_crawled_at'], name='osint_seedw_last_cr_673111_idx'), + ), + migrations.AddIndex( + model_name='crawledcontent', + index=models.Index(fields=['seed_website', 'crawled_at'], name='osint_crawl_seed_we_eb78f4_idx'), + ), + migrations.AddIndex( + model_name='crawledcontent', + index=models.Index(fields=['has_potential_scam', 'confidence_score'], name='osint_crawl_has_pot_9317d0_idx'), + ), + migrations.AddIndex( + model_name='crawledcontent', + index=models.Index(fields=['content_hash'], name='osint_crawl_content_17d05a_idx'), + ), + migrations.AlterUniqueTogether( + name='crawledcontent', + unique_together={('url', 'content_hash')}, + ), + ] diff --git a/osint/migrations/__init__.py b/osint/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/osint/models.py b/osint/models.py new file mode 100644 index 0000000..cb8508d --- /dev/null +++ b/osint/models.py @@ -0,0 +1,468 @@ +""" +OSINT (Open Source Intelligence) integration models. +""" +from django.db import models +from django.contrib.auth import get_user_model +from reports.models import ScamReport + +User = get_user_model() + + +class OSINTTask(models.Model): + """ + Background tasks for OSINT data collection. + """ + TASK_TYPE_CHOICES = [ + ('domain_analysis', 'Domain Analysis'), + ('url_analysis', 'URL Analysis'), + ('email_analysis', 'Email Analysis'), + ('phone_analysis', 'Phone Analysis'), + ('whois_lookup', 'WHOIS Lookup'), + ('dns_lookup', 'DNS Lookup'), + ('ssl_check', 'SSL Certificate Check'), + ('archive_check', 'Archive Check'), + ('business_registry', 'Business Registry Check'), + ('social_media', 'Social Media Check'), + ] + + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('running', 'Running'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ('cancelled', 'Cancelled'), + ] + + report = models.ForeignKey( + ScamReport, + on_delete=models.CASCADE, + related_name='osint_tasks' + ) + task_type = models.CharField( + max_length=50, + choices=TASK_TYPE_CHOICES + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending' + ) + parameters = models.JSONField( + default=dict, + help_text='Task parameters (e.g., URL, email, phone)' + ) + result = models.JSONField( + default=dict, + blank=True, + help_text='Task result data' + ) + error_message = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + retry_count = models.IntegerField(default=0) + + class Meta: + db_table = 'osint_osinttask' + verbose_name = 'OSINT Task' + verbose_name_plural = 'OSINT Tasks' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['status', 'created_at']), + models.Index(fields=['report', 'task_type']), + ] + + def __str__(self): + return f"{self.get_task_type_display()} for Report #{self.report.id} - {self.get_status_display()}" + + +class OSINTResult(models.Model): + """ + OSINT investigation results. + """ + DATA_TYPE_CHOICES = [ + ('whois', 'WHOIS Data'), + ('dns', 'DNS Records'), + ('ssl', 'SSL Certificate'), + ('archive', 'Archive Data'), + ('email', 'Email Data'), + ('phone', 'Phone Data'), + ('business', 'Business Registry Data'), + ('social', 'Social Media Data'), + ('reputation', 'Reputation Data'), + ] + + report = models.ForeignKey( + ScamReport, + on_delete=models.CASCADE, + related_name='osint_results' + ) + source = models.CharField( + max_length=100, + help_text='OSINT source/service name' + ) + data_type = models.CharField( + max_length=50, + choices=DATA_TYPE_CHOICES + ) + raw_data = models.JSONField( + default=dict, + help_text='Raw data from OSINT source' + ) + processed_data = models.JSONField( + default=dict, + blank=True, + help_text='Processed/cleaned data' + ) + confidence_level = models.IntegerField( + default=0, + help_text='Confidence level (0-100)' + ) + is_verified = models.BooleanField( + default=False, + help_text='Manually verified by moderator' + ) + collected_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'osint_osintresult' + verbose_name = 'OSINT Result' + verbose_name_plural = 'OSINT Results' + ordering = ['-collected_at'] + indexes = [ + models.Index(fields=['report', 'data_type']), + models.Index(fields=['confidence_level', 'is_verified']), + ] + + def __str__(self): + return f"{self.get_data_type_display()} from {self.source} for Report #{self.report.id}" + + +class OSINTConfiguration(models.Model): + """ + Configuration for OSINT services and APIs. + """ + service_name = models.CharField(max_length=100, unique=True) + api_key = models.CharField( + max_length=255, + blank=True, + help_text='Encrypted API key' + ) + api_url = models.URLField(blank=True) + is_active = models.BooleanField(default=True) + rate_limit = models.IntegerField( + default=100, + help_text='Requests per hour' + ) + configuration = models.JSONField( + default=dict, + blank=True, + help_text='Additional configuration' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'osint_osintconfiguration' + verbose_name = 'OSINT Configuration' + verbose_name_plural = 'OSINT Configurations' + + def __str__(self): + return f"{self.service_name} ({'Active' if self.is_active else 'Inactive'})" + + +class SeedWebsite(models.Model): + """ + Seed websites for OSINT crawling. + """ + PRIORITY_CHOICES = [ + ('high', 'High'), + ('medium', 'Medium'), + ('low', 'Low'), + ] + + url = models.URLField( + max_length=500, + help_text='Base URL to crawl' + ) + name = models.CharField( + max_length=200, + help_text='Friendly name for this seed website' + ) + description = models.TextField( + blank=True, + help_text='Description of the website' + ) + is_active = models.BooleanField( + default=True, + help_text='Enable/disable crawling for this website' + ) + priority = models.CharField( + max_length=10, + choices=PRIORITY_CHOICES, + default='medium', + help_text='Crawling priority' + ) + crawl_depth = models.IntegerField( + default=2, + help_text='Maximum depth to crawl (0 = only this page, 1 = this page + direct links, etc.)' + ) + crawl_interval_hours = models.IntegerField( + default=24, + help_text='Hours between crawls' + ) + allowed_domains = models.JSONField( + default=list, + blank=True, + help_text='List of allowed domains to crawl (empty = same domain only)' + ) + user_agent = models.CharField( + max_length=255, + blank=True, + default='Mozilla/5.0 (compatible; OSINTBot/1.0)', + help_text='User agent string for requests' + ) + last_crawled_at = models.DateTimeField(null=True, blank=True) + pages_crawled = models.IntegerField(default=0) + matches_found = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='created_seed_websites' + ) + + class Meta: + db_table = 'osint_seedwebsite' + verbose_name = 'Seed Website' + verbose_name_plural = 'Seed Websites' + ordering = ['-priority', '-last_crawled_at'] + indexes = [ + models.Index(fields=['is_active', 'priority']), + models.Index(fields=['last_crawled_at']), + ] + + def __str__(self): + return f"{self.name} ({self.url})" + + +class OSINTKeyword(models.Model): + """ + Keywords and patterns to search for during OSINT crawling. + """ + TYPE_CHOICES = [ + ('exact', 'Exact Match'), + ('regex', 'Regular Expression'), + ('phrase', 'Phrase Match'), + ('domain', 'Domain Pattern'), + ('email', 'Email Pattern'), + ('phone', 'Phone Pattern'), + ] + + keyword = models.CharField( + max_length=500, + help_text='Keyword, phrase, or regex pattern to search for' + ) + name = models.CharField( + max_length=200, + help_text='Friendly name for this keyword' + ) + description = models.TextField( + blank=True, + help_text='Description of what this keyword detects' + ) + keyword_type = models.CharField( + max_length=20, + choices=TYPE_CHOICES, + default='phrase', + help_text='Type of matching to perform' + ) + is_active = models.BooleanField( + default=True, + help_text='Enable/disable this keyword' + ) + case_sensitive = models.BooleanField( + default=False, + help_text='Case sensitive matching' + ) + confidence_score = models.IntegerField( + default=50, + help_text='Default confidence score (0-100) when this keyword matches' + ) + auto_approve = models.BooleanField( + default=False, + help_text='Auto-approve reports matching this keyword (requires high confidence)' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='created_keywords' + ) + + class Meta: + db_table = 'osint_keyword' + verbose_name = 'OSINT Keyword' + verbose_name_plural = 'OSINT Keywords' + ordering = ['-is_active', 'name'] + indexes = [ + models.Index(fields=['is_active', 'keyword_type']), + ] + + def __str__(self): + return f"{self.name} ({self.keyword_type})" + + +class CrawledContent(models.Model): + """ + Content crawled from seed websites. + """ + seed_website = models.ForeignKey( + SeedWebsite, + on_delete=models.CASCADE, + related_name='crawled_contents' + ) + url = models.URLField( + max_length=1000, + help_text='URL of the crawled page' + ) + title = models.CharField( + max_length=500, + blank=True, + help_text='Page title' + ) + content = models.TextField( + help_text='Crawled page content' + ) + html_content = models.TextField( + blank=True, + help_text='Raw HTML content' + ) + matched_keywords = models.ManyToManyField( + OSINTKeyword, + blank=True, + related_name='matched_contents', + help_text='Keywords that matched this content' + ) + match_count = models.IntegerField( + default=0, + help_text='Number of keyword matches found' + ) + confidence_score = models.IntegerField( + default=0, + help_text='Calculated confidence score based on matches' + ) + has_potential_scam = models.BooleanField( + default=False, + help_text='Flagged as potential scam based on keyword matches' + ) + crawled_at = models.DateTimeField(auto_now_add=True) + http_status = models.IntegerField( + null=True, + blank=True, + help_text='HTTP status code' + ) + content_hash = models.CharField( + max_length=64, + blank=True, + help_text='SHA256 hash of content for deduplication' + ) + + class Meta: + db_table = 'osint_crawledcontent' + verbose_name = 'Crawled Content' + verbose_name_plural = 'Crawled Contents' + ordering = ['-crawled_at', '-confidence_score'] + indexes = [ + models.Index(fields=['seed_website', 'crawled_at']), + models.Index(fields=['has_potential_scam', 'confidence_score']), + models.Index(fields=['content_hash']), + ] + unique_together = [['url', 'content_hash']] + + def __str__(self): + return f"{self.title or self.url} - {self.match_count} matches" + + +class AutoGeneratedReport(models.Model): + """ + Automatically generated scam reports from OSINT crawling. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Review'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('published', 'Published'), + ] + + crawled_content = models.OneToOneField( + CrawledContent, + on_delete=models.CASCADE, + related_name='auto_report' + ) + report = models.ForeignKey( + ScamReport, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='auto_generated_reports', + help_text='Linked scam report (created when approved)' + ) + title = models.CharField( + max_length=500, + help_text='Auto-generated report title' + ) + description = models.TextField( + help_text='Auto-generated report description' + ) + source_url = models.URLField( + max_length=1000, + help_text='Source URL where scam was found' + ) + matched_keywords = models.ManyToManyField( + OSINTKeyword, + related_name='generated_reports' + ) + confidence_score = models.IntegerField( + default=0, + help_text='Confidence score (0-100)' + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending', + help_text='Review status' + ) + review_notes = models.TextField( + blank=True, + help_text='Notes from moderator/admin review' + ) + reviewed_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reviewed_auto_reports' + ) + reviewed_at = models.DateTimeField(null=True, blank=True) + published_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'osint_autogeneratedreport' + verbose_name = 'Auto-Generated Report' + verbose_name_plural = 'Auto-Generated Reports' + ordering = ['-created_at', '-confidence_score'] + indexes = [ + models.Index(fields=['status', 'confidence_score']), + models.Index(fields=['created_at']), + ] + + def __str__(self): + return f"{self.title} - {self.get_status_display()}" diff --git a/osint/tasks.py b/osint/tasks.py new file mode 100644 index 0000000..91a5dee --- /dev/null +++ b/osint/tasks.py @@ -0,0 +1,75 @@ +""" +Celery tasks for OSINT crawling. +""" +from celery import shared_task +from django.core.management import call_command +from django.utils import timezone +from datetime import timedelta +from .models import SeedWebsite, AutoGeneratedReport + + +@shared_task +def crawl_osint_seeds(): + """ + Periodic task to crawl all due seed websites. + This should be scheduled to run periodically (e.g., every hour). + """ + try: + call_command('crawl_osint', '--all', verbosity=0) + return "OSINT crawling completed successfully" + except Exception as e: + return f"OSINT crawling failed: {str(e)}" + + +@shared_task +def crawl_specific_seed(seed_id): + """ + Crawl a specific seed website. + """ + try: + call_command('crawl_osint', '--seed-id', str(seed_id), verbosity=0) + return f"Seed website {seed_id} crawled successfully" + except Exception as e: + return f"Seed website {seed_id} crawling failed: {str(e)}" + + +@shared_task +def auto_approve_high_confidence_reports(): + """ + Auto-approve reports with very high confidence scores and auto-approve keywords. + """ + from reports.models import ScamReport + + # Get auto-reports that should be auto-approved + auto_reports = AutoGeneratedReport.objects.filter( + status='pending', + confidence_score__gte=80 + ).prefetch_related('matched_keywords') + + approved_count = 0 + for auto_report in auto_reports: + # Check if any matched keyword has auto_approve enabled + if any(kw.auto_approve for kw in auto_report.matched_keywords.all()): + # Approve and publish + from osint.views import ApproveAutoReportView + # Create report directly + report = ScamReport.objects.create( + title=auto_report.title, + description=auto_report.description, + reported_url=auto_report.source_url, + scam_type='other', + status='verified', + verification_score=auto_report.confidence_score, + is_public=True, + is_anonymous=True, + is_auto_discovered=True, # Mark as auto-discovered + ) + + auto_report.report = report + auto_report.status = 'published' + auto_report.published_at = timezone.now() + auto_report.save() + approved_count += 1 + + return f"Auto-approved {approved_count} reports" + diff --git a/osint/tests.py b/osint/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/osint/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/osint/urls.py b/osint/urls.py new file mode 100644 index 0000000..12912e1 --- /dev/null +++ b/osint/urls.py @@ -0,0 +1,35 @@ +""" +URL configuration for osint app. +""" +from django.urls import path +from . import views + +app_name = 'osint' + +urlpatterns = [ + # Admin Dashboard (Main OSINT Management) + path('admin-dashboard/', views.OSINTAdminDashboardView.as_view(), name='admin_dashboard'), + + # Seed Website Management + path('admin-dashboard/seeds/add/', views.SeedWebsiteCreateView.as_view(), name='seed_create'), + path('admin-dashboard/seeds//edit/', views.SeedWebsiteUpdateView.as_view(), name='seed_edit'), + path('admin-dashboard/seeds//delete/', views.SeedWebsiteDeleteView.as_view(), name='seed_delete'), + + # Keyword Management + path('admin-dashboard/keywords/add/', views.OSINTKeywordCreateView.as_view(), name='keyword_create'), + path('admin-dashboard/keywords//edit/', views.OSINTKeywordUpdateView.as_view(), name='keyword_edit'), + path('admin-dashboard/keywords//delete/', views.OSINTKeywordDeleteView.as_view(), name='keyword_delete'), + + # Crawling Control + path('admin-dashboard/start-crawling/', views.StartCrawlingView.as_view(), name='start_crawling'), + + # Legacy/Moderator Views + path('tasks/', views.OSINTTaskListView.as_view(), name='task_list'), + path('tasks//', views.OSINTTaskDetailView.as_view(), name='task_detail'), + path('results//', views.OSINTResultListView.as_view(), name='result_list'), + path('auto-reports/', views.AutoReportListView.as_view(), name='auto_report_list'), + path('auto-reports//', views.AutoReportDetailView.as_view(), name='auto_report_detail'), + path('auto-reports//approve/', views.ApproveAutoReportView.as_view(), name='approve_auto_report'), + path('auto-reports//reject/', views.RejectAutoReportView.as_view(), name='reject_auto_report'), +] + diff --git a/osint/views.py b/osint/views.py new file mode 100644 index 0000000..cead158 --- /dev/null +++ b/osint/views.py @@ -0,0 +1,346 @@ +""" +Views for osint app. +""" +from django.shortcuts import get_object_or_404, redirect +from django.views.generic import ListView, DetailView, UpdateView, TemplateView, CreateView, DeleteView +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib import messages +from django.urls import reverse_lazy +from django.utils import timezone +from django.db import transaction +from django.db.models import Count, Q +from django.http import JsonResponse +from django.core.management import call_command +from django.core.management.base import CommandError +import subprocess +import threading +from reports.models import ScamReport +from .models import OSINTTask, OSINTResult, AutoGeneratedReport, SeedWebsite, OSINTKeyword, CrawledContent +from .forms import SeedWebsiteForm, OSINTKeywordForm + + +class ModeratorRequiredMixin(UserPassesTestMixin): + """Mixin to require moderator role.""" + def test_func(self): + return self.request.user.is_authenticated and self.request.user.is_moderator() + + +class OSINTTaskListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView): + """List OSINT tasks.""" + model = OSINTTask + template_name = 'osint/task_list.html' + context_object_name = 'tasks' + paginate_by = 50 + + def get_queryset(self): + status = self.request.GET.get('status', '') + queryset = OSINTTask.objects.select_related('report') + if status: + queryset = queryset.filter(status=status) + return queryset.order_by('-created_at') + + +class OSINTTaskDetailView(LoginRequiredMixin, ModeratorRequiredMixin, DetailView): + """View OSINT task details.""" + model = OSINTTask + template_name = 'osint/task_detail.html' + context_object_name = 'task' + + +class OSINTResultListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView): + """List OSINT results for a report.""" + model = OSINTResult + template_name = 'osint/result_list.html' + context_object_name = 'results' + + def get_queryset(self): + report = get_object_or_404(ScamReport, pk=self.kwargs['report_id']) + return OSINTResult.objects.filter(report=report).order_by('-collected_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['report'] = get_object_or_404(ScamReport, pk=self.kwargs['report_id']) + return context + + +class AutoReportListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView): + """List auto-generated reports for review.""" + model = AutoGeneratedReport + template_name = 'osint/auto_report_list.html' + context_object_name = 'auto_reports' + paginate_by = 20 + + def get_queryset(self): + status = self.request.GET.get('status', 'pending') + queryset = AutoGeneratedReport.objects.select_related( + 'crawled_content', 'reviewed_by', 'report' + ).prefetch_related('matched_keywords') + + if status: + queryset = queryset.filter(status=status) + + return queryset.order_by('-confidence_score', '-created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['pending_count'] = AutoGeneratedReport.objects.filter(status='pending').count() + context['approved_count'] = AutoGeneratedReport.objects.filter(status='approved').count() + context['published_count'] = AutoGeneratedReport.objects.filter(status='published').count() + context['rejected_count'] = AutoGeneratedReport.objects.filter(status='rejected').count() + return context + + +class AutoReportDetailView(LoginRequiredMixin, ModeratorRequiredMixin, DetailView): + """View auto-generated report details.""" + model = AutoGeneratedReport + template_name = 'osint/auto_report_detail.html' + context_object_name = 'auto_report' + + def get_queryset(self): + return AutoGeneratedReport.objects.select_related( + 'crawled_content', 'crawled_content__seed_website', + 'reviewed_by', 'report' + ).prefetch_related('matched_keywords') + + +class ApproveAutoReportView(LoginRequiredMixin, ModeratorRequiredMixin, SuccessMessageMixin, UpdateView): + """Approve an auto-generated report.""" + model = AutoGeneratedReport + fields = [] + template_name = 'osint/approve_auto_report.html' + success_message = "Auto-generated report approved successfully!" + + def form_valid(self, form): + auto_report = form.instance + + with transaction.atomic(): + # Update auto report + auto_report.status = 'approved' + auto_report.reviewed_by = self.request.user + auto_report.reviewed_at = timezone.now() + auto_report.save() + + # Create the actual scam report + from reports.models import ScamReport + + report = ScamReport.objects.create( + title=auto_report.title, + description=auto_report.description, + reported_url=auto_report.source_url, + scam_type='other', # Default, can be updated + status='verified', + verification_score=auto_report.confidence_score, + is_public=True, + is_anonymous=True, # System-generated + is_auto_discovered=True, # Mark as auto-discovered + ) + + auto_report.report = report + auto_report.status = 'published' + auto_report.published_at = timezone.now() + auto_report.save() + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('osint:auto_report_list') + + +class RejectAutoReportView(LoginRequiredMixin, ModeratorRequiredMixin, SuccessMessageMixin, UpdateView): + """Reject an auto-generated report.""" + model = AutoGeneratedReport + fields = [] + template_name = 'osint/reject_auto_report.html' + success_message = "Auto-generated report rejected." + + def form_valid(self, form): + auto_report = form.instance + auto_report.status = 'rejected' + auto_report.reviewed_by = self.request.user + auto_report.reviewed_at = timezone.now() + auto_report.review_notes = self.request.POST.get('review_notes', '').strip() + auto_report.save() + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('osint:auto_report_list') + + +class AdminRequiredMixin(UserPassesTestMixin): + """Mixin to require admin role.""" + def test_func(self): + return self.request.user.is_authenticated and self.request.user.is_administrator() + + +class OSINTAdminDashboardView(LoginRequiredMixin, AdminRequiredMixin, TemplateView): + """Comprehensive OSINT admin dashboard.""" + template_name = 'osint/admin_dashboard.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + now = timezone.now() + + # Seed Website Statistics + context['total_seeds'] = SeedWebsite.objects.count() + context['active_seeds'] = SeedWebsite.objects.filter(is_active=True).count() + context['seed_websites'] = SeedWebsite.objects.all().order_by('-priority', '-last_crawled_at')[:10] + + # Keyword Statistics + context['total_keywords'] = OSINTKeyword.objects.count() + context['active_keywords'] = OSINTKeyword.objects.filter(is_active=True).count() + context['keywords'] = OSINTKeyword.objects.all().order_by('-is_active', 'name')[:10] + + # Crawling Statistics + context['total_crawled'] = CrawledContent.objects.count() + context['potential_scams'] = CrawledContent.objects.filter(has_potential_scam=True).count() + context['recent_crawled'] = CrawledContent.objects.order_by('-crawled_at')[:5] + + # Auto-Report Statistics + context['pending_reports'] = AutoGeneratedReport.objects.filter(status='pending').count() + context['approved_reports'] = AutoGeneratedReport.objects.filter(status='approved').count() + context['published_reports'] = AutoGeneratedReport.objects.filter(status='published').count() + context['rejected_reports'] = AutoGeneratedReport.objects.filter(status='rejected').count() + context['recent_auto_reports'] = AutoGeneratedReport.objects.order_by('-created_at')[:5] + + # Overall Statistics + context['total_pages_crawled'] = SeedWebsite.objects.aggregate( + total=Count('pages_crawled') + )['total'] or 0 + context['total_matches'] = SeedWebsite.objects.aggregate( + total=Count('matches_found') + )['total'] or 0 + + # Seed websites due for crawling + due_seeds = [] + for seed in SeedWebsite.objects.filter(is_active=True): + if not seed.last_crawled_at: + due_seeds.append(seed) + else: + hours_since = (now - seed.last_crawled_at).total_seconds() / 3600 + if hours_since >= seed.crawl_interval_hours: + due_seeds.append(seed) + context['due_for_crawling'] = due_seeds[:5] + + return context + + +class SeedWebsiteCreateView(LoginRequiredMixin, AdminRequiredMixin, SuccessMessageMixin, CreateView): + """Create a new seed website.""" + model = SeedWebsite + form_class = SeedWebsiteForm + template_name = 'osint/seed_website_form.html' + success_message = "Seed website created successfully!" + + def form_valid(self, form): + form.instance.created_by = self.request.user + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('osint:admin_dashboard') + + +class SeedWebsiteUpdateView(LoginRequiredMixin, AdminRequiredMixin, SuccessMessageMixin, UpdateView): + """Update a seed website.""" + model = SeedWebsite + form_class = SeedWebsiteForm + template_name = 'osint/seed_website_form.html' + success_message = "Seed website updated successfully!" + + def get_success_url(self): + return reverse_lazy('osint:admin_dashboard') + + +class SeedWebsiteDeleteView(LoginRequiredMixin, AdminRequiredMixin, SuccessMessageMixin, DeleteView): + """Delete a seed website.""" + model = SeedWebsite + template_name = 'osint/seed_website_confirm_delete.html' + success_message = "Seed website deleted successfully!" + + def get_success_url(self): + return reverse_lazy('osint:admin_dashboard') + + +class OSINTKeywordCreateView(LoginRequiredMixin, AdminRequiredMixin, SuccessMessageMixin, CreateView): + """Create a new OSINT keyword.""" + model = OSINTKeyword + form_class = OSINTKeywordForm + template_name = 'osint/keyword_form.html' + success_message = "Keyword created successfully!" + + def form_valid(self, form): + form.instance.created_by = self.request.user + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('osint:admin_dashboard') + + +class OSINTKeywordUpdateView(LoginRequiredMixin, AdminRequiredMixin, SuccessMessageMixin, UpdateView): + """Update an OSINT keyword.""" + model = OSINTKeyword + form_class = OSINTKeywordForm + template_name = 'osint/keyword_form.html' + success_message = "Keyword updated successfully!" + + def get_success_url(self): + return reverse_lazy('osint:admin_dashboard') + + +class OSINTKeywordDeleteView(LoginRequiredMixin, AdminRequiredMixin, SuccessMessageMixin, DeleteView): + """Delete an OSINT keyword.""" + model = OSINTKeyword + template_name = 'osint/keyword_confirm_delete.html' + success_message = "Keyword deleted successfully!" + + def get_success_url(self): + return reverse_lazy('osint:admin_dashboard') + + +class StartCrawlingView(LoginRequiredMixin, AdminRequiredMixin, TemplateView): + """Start OSINT crawling.""" + template_name = 'osint/start_crawling.html' + + def post(self, request, *args, **kwargs): + seed_id = request.POST.get('seed_id') + max_pages = request.POST.get('max_pages', 50) + delay = request.POST.get('delay', 1.0) + + def run_crawl(): + import sys + import os + import django + from django.db import connections + + # Ensure Django is set up for this thread + django.setup() + + try: + if seed_id: + call_command('crawl_osint', '--seed-id', str(seed_id), + '--max-pages', str(max_pages), '--delay', str(delay), verbosity=1) + else: + call_command('crawl_osint', '--all', + '--max-pages', str(max_pages), '--delay', str(delay), verbosity=1) + except Exception as e: + # Log error to a file or database for debugging + import traceback + error_msg = f"Crawling error: {str(e)}\n{traceback.format_exc()}" + print(error_msg, file=sys.stderr) + # You could also log to a file or database here + finally: + # Close database connections + connections.close_all() + + # Run in background thread + thread = threading.Thread(target=run_crawl) + thread.daemon = True + thread.start() + + messages.success(request, f'Crawling started in background. Check results in a few minutes. (Max pages: {max_pages}, Delay: {delay}s)') + return redirect('osint:admin_dashboard') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['seed_websites'] = SeedWebsite.objects.filter(is_active=True) + return context diff --git a/reports/__init__.py b/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports/admin.py b/reports/admin.py new file mode 100644 index 0000000..b7a3a1c --- /dev/null +++ b/reports/admin.py @@ -0,0 +1,247 @@ +""" +Admin configuration for reports app. +""" +from django.contrib import admin +from django.utils.html import format_html +from django import forms +from .models import ScamTag, ScamReport, ScamVerification, SiteSettings, TakedownRequest + + +@admin.register(ScamTag) +class ScamTagAdmin(admin.ModelAdmin): + """Scam tag admin.""" + list_display = ('name', 'slug', 'color') + prepopulated_fields = {'slug': ('name',)} + search_fields = ('name',) + + +@admin.register(ScamReport) +class ScamReportAdmin(admin.ModelAdmin): + """Scam report admin.""" + list_display = ('title', 'reporter', 'scam_type', 'status', 'verification_score', 'created_at') + list_filter = ('status', 'scam_type', 'is_public', 'created_at') + search_fields = ('title', 'description', 'reported_url', 'reported_email', 'reported_phone') + readonly_fields = ('created_at', 'updated_at', 'verified_at', 'reporter_ip') + filter_horizontal = ('tags',) + date_hierarchy = 'created_at' + + fieldsets = ( + ('Report Information', { + 'fields': ('title', 'description', 'scam_type', 'tags') + }), + ('Reported Entities', { + 'fields': ('reported_url', 'reported_email', 'reported_phone', 'reported_company') + }), + ('Reporter', { + 'fields': ('reporter', 'is_anonymous', 'reporter_ip') + }), + ('Status', { + 'fields': ('status', 'verification_score', 'is_public', 'verified_at') + }), + ('Evidence', { + 'fields': ('evidence_files',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at') + }), + ) + + +@admin.register(ScamVerification) +class ScamVerificationAdmin(admin.ModelAdmin): + """Scam verification admin.""" + list_display = ('report', 'verification_method', 'confidence_score', 'verified_by', 'created_at') + list_filter = ('verification_method', 'created_at') + search_fields = ('report__title', 'notes') + readonly_fields = ('created_at',) + + +@admin.register(SiteSettings) +class SiteSettingsAdmin(admin.ModelAdmin): + """Site settings admin - singleton pattern.""" + + def has_add_permission(self, request): + # Only allow one instance + return not SiteSettings.objects.exists() + + def has_delete_permission(self, request, obj=None): + # Prevent deletion + return False + + fieldsets = ( + ('Контактна Информация', { + 'fields': ('contact_email', 'contact_phone', 'contact_address'), + 'description': 'Тези настройки се използват навсякъде в сайта - в подножието, страницата за контакти, структурираните данни и др.' + }), + ('Настройки на Имейл Сървър', { + 'fields': ( + 'email_backend', + 'email_host', + 'email_port', + 'email_use_tls', + 'email_use_ssl', + 'email_host_user', + 'email_host_password', + 'default_from_email', + 'email_timeout', + ), + 'description': 'Настройки за SMTP сървър. Използват се за всички имейли в платформата - контактни форми, нулиране на пароли, уведомления и др. Паролата се криптира автоматично.' + }), + ('Информация', { + 'fields': ('updated_at',), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('updated_at',) + + def get_form(self, request, obj=None, **kwargs): + """Customize form to handle password field.""" + form = super().get_form(request, obj, **kwargs) + + # Make password field a password input + form.base_fields['email_host_password'].widget = forms.PasswordInput(attrs={ + 'class': 'vTextField', + 'autocomplete': 'new-password' + }) + + # Add help text + form.base_fields['email_host_password'].help_text = 'Въведете нова парола или оставете празно, за да запазите текущата.' + + return form + + def save_model(self, request, obj, form, change): + """Handle password encryption and clear cache.""" + # If password field is empty and we're editing, keep the old password + if change and not form.cleaned_data.get('email_host_password'): + old_obj = self.model.objects.get(pk=obj.pk) + obj.email_host_password = old_obj.email_host_password + + # Validate TLS/SSL are mutually exclusive + if obj.email_use_tls and obj.email_use_ssl: + from django.contrib import messages + messages.warning(request, 'TLS и SSL не могат да бъдат активирани едновременно. SSL е деактивиран, използва се TLS.') + obj.email_use_ssl = False + + # Save will encrypt the password if it's provided + super().save_model(request, obj, form, change) + # Clear email backend cache to reload settings + from django.core.cache import cache + cache.delete('site_settings') + + def changelist_view(self, request, extra_context=None): + # Redirect to the single instance if it exists + from django.shortcuts import redirect + from django.urls import reverse + + if SiteSettings.objects.exists(): + obj = SiteSettings.objects.get(pk=1) + url = reverse('admin:reports_sitesettings_change', args=[str(obj.pk)]) + return redirect(url) + return super().changelist_view(request, extra_context) + + def response_change(self, request, obj): + """Handle test email button.""" + if "_test_email" in request.POST: + try: + from django.core.mail import send_mail + from django.contrib import messages + + test_email = request.POST.get('test_email_address', request.user.email) + if not test_email: + messages.error(request, 'Моля, въведете имейл адрес за тест.') + return super().response_change(request, obj) + + # Check if SMTP is configured + if obj.email_backend == 'django.core.mail.backends.smtp.EmailBackend' and not obj.email_host: + messages.warning(request, 'SMTP сървърът не е конфигуриран. Моля, въведете Email Host преди изпращане на тестов имейл.') + return super().response_change(request, obj) + + # Get the connection to check backend type + from django.core.mail import get_connection, EmailMessage + connection = get_connection() + backend_name = connection.__class__.__name__ + import logging + logger = logging.getLogger(__name__) + logger.info(f"Using email backend: {backend_name}") + + # Check underlying backend + if hasattr(connection, '_backend') and connection._backend: + underlying_backend = connection._backend.__class__.__name__ + logger.info(f"Underlying backend: {underlying_backend}") + + # Send email using EmailMessage for better error handling + email = EmailMessage( + subject='Тестов Имейл от Портал за Докладване на Измами', + body='Това е тестов имейл за проверка на настройките на имейл сървъра. Ако получавате този имейл, настройките са правилни.', + from_email=obj.default_from_email, + to=[test_email], + connection=connection, + ) + + result = email.send(fail_silently=False) + + logger.info(f"Email send result: {result} (1 = success, 0 = failed)") + + # Check which backend was actually used + if 'Console' in backend_name or 'console' in str(connection.__class__.__module__): + messages.warning(request, f'Имейлът е изпратен чрез конзолен backend (за разработка). За реално изпращане, конфигурирайте SMTP настройките. Backend: {backend_name}') + else: + messages.success(request, f'Тестов имейл изпратен успешно до {test_email}! Използван backend: {backend_name}') + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.exception("Error sending test email") + error_msg = str(e) + if 'authentication failed' in error_msg.lower(): + messages.error(request, f'Грешка при удостоверяване: Проверете потребителското име и паролата.') + elif 'connection' in error_msg.lower() or 'timeout' in error_msg.lower(): + messages.error(request, f'Грешка при свързване: Проверете SMTP сървъра и порта.') + else: + messages.error(request, f'Грешка при изпращане на тестов имейл: {error_msg}') + + return super().response_change(request, obj) + + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + extra_context = extra_context or {} + if object_id: + obj = self.get_object(request, object_id) + if obj: + extra_context['show_test_email'] = True + return super().changeform_view(request, object_id, form_url, extra_context) + + +@admin.register(TakedownRequest) +class TakedownRequestAdmin(admin.ModelAdmin): + """Takedown request admin.""" + list_display = ('report', 'requester_name', 'requester_email', 'status', 'created_at', 'reviewed_by') + list_filter = ('status', 'created_at', 'reviewed_at') + search_fields = ('requester_name', 'requester_email', 'report__title', 'reason') + readonly_fields = ('created_at', 'updated_at', 'ip_address', 'user_agent') + date_hierarchy = 'created_at' + + fieldsets = ( + ('Информация за Доклада', { + 'fields': ('report',) + }), + ('Информация за Заявителя', { + 'fields': ('requester_name', 'requester_email', 'requester_phone') + }), + ('Детайли на Заявката', { + 'fields': ('reason', 'evidence') + }), + ('Статус и Преглед', { + 'fields': ('status', 'reviewed_by', 'review_notes', 'reviewed_at') + }), + ('Техническа Информация', { + 'fields': ('ip_address', 'user_agent', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def save_model(self, request, obj, form, change): + if change and 'status' in form.changed_data and obj.status in ['approved', 'rejected']: + from django.utils import timezone + obj.reviewed_by = request.user + obj.reviewed_at = timezone.now() + super().save_model(request, obj, form, change) diff --git a/reports/apps.py b/reports/apps.py new file mode 100644 index 0000000..8aa073c --- /dev/null +++ b/reports/apps.py @@ -0,0 +1,15 @@ +""" +App configuration for reports app. +""" +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'reports' + + def ready(self): + """Set up signals and configure email settings.""" + # Note: Email settings are loaded dynamically via the email backend + # No need to access database here to avoid warnings + pass diff --git a/reports/email_backend.py b/reports/email_backend.py new file mode 100644 index 0000000..d9440c4 --- /dev/null +++ b/reports/email_backend.py @@ -0,0 +1,123 @@ +""" +Custom email backend that uses SiteSettings for configuration. +""" +from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend +from django.core.mail.backends.console import EmailBackend as ConsoleEmailBackend +from django.core.mail.backends.base import BaseEmailBackend +from django.conf import settings +from .models import SiteSettings + + +class SiteSettingsEmailBackend(BaseEmailBackend): + """ + Email backend that dynamically loads settings from SiteSettings model. + Falls back to Django settings if SiteSettings are not configured. + """ + + def __init__(self, fail_silently=False, **kwargs): + super().__init__(fail_silently=fail_silently) + self._backend = None + self._backend_instance = None + self._load_backend() + + def _load_backend(self): + """Load the appropriate email backend based on SiteSettings.""" + import logging + logger = logging.getLogger(__name__) + + try: + site_settings = SiteSettings.get_settings() + backend_class = site_settings.email_backend + logger.info(f"Loading email backend: {backend_class}") + + # If using SMTP, configure it with SiteSettings + if backend_class == 'django.core.mail.backends.smtp.EmailBackend': + # Get decrypted password + email_password = site_settings.get_email_password() if hasattr(site_settings, 'get_email_password') else site_settings.email_host_password + + # Check if SMTP is properly configured + email_host = site_settings.email_host or getattr(settings, 'EMAIL_HOST', '') + + # If no host is configured, fall back to console backend + if not email_host: + logger.warning("Email host not configured, using console backend") + self._backend = ConsoleEmailBackend(fail_silently=self.fail_silently) + else: + # Ensure TLS and SSL are mutually exclusive + use_tls = site_settings.email_use_tls + use_ssl = site_settings.email_use_ssl + + # If both are True, prioritize TLS (common case) + if use_tls and use_ssl: + use_ssl = False + logger.warning("Both TLS and SSL were enabled. Disabling SSL and using TLS only.") + + logger.info(f"Configuring SMTP: host={email_host}, port={site_settings.email_port}, user={site_settings.email_host_user}, tls={use_tls}, ssl={use_ssl}") + + self._backend = SMTPEmailBackend( + host=email_host, + port=site_settings.email_port or getattr(settings, 'EMAIL_PORT', 587), + username=site_settings.email_host_user or getattr(settings, 'EMAIL_HOST_USER', ''), + password=email_password or getattr(settings, 'EMAIL_HOST_PASSWORD', ''), + use_tls=use_tls, + use_ssl=use_ssl, + timeout=site_settings.email_timeout or getattr(settings, 'EMAIL_TIMEOUT', 10), + fail_silently=self.fail_silently, + ) + logger.info("SMTP backend configured successfully") + elif backend_class == 'django.core.mail.backends.console.EmailBackend': + logger.info("Using console email backend") + self._backend = ConsoleEmailBackend(fail_silently=self.fail_silently) + else: + # For other backends, try to import and instantiate + from django.utils.module_loading import import_string + backend_class_obj = import_string(backend_class) + self._backend = backend_class_obj(fail_silently=self.fail_silently) + logger.info(f"Loaded custom backend: {backend_class}") + except Exception as e: + # Fallback to console backend if there's an error + logger.exception(f"Error loading email backend from SiteSettings: {e}. Using console backend.") + self._backend = ConsoleEmailBackend(fail_silently=self.fail_silently) + + def open(self): + """Open a network connection.""" + if self._backend: + return self._backend.open() + return False + + def close(self): + """Close the network connection.""" + if self._backend: + return self._backend.close() + + def send_messages(self, email_messages): + """Send one or more EmailMessage objects and return the number sent.""" + import logging + logger = logging.getLogger(__name__) + + # Reload backend before sending to get latest settings + # This ensures settings changes take effect immediately + self._load_backend() + if self._backend: + try: + # Log email details for debugging + for msg in email_messages: + logger.info(f"Sending email: To={msg.to}, Subject={msg.subject}, From={msg.from_email}") + + result = self._backend.send_messages(email_messages) + logger.info(f"Successfully sent {result} email message(s)") + return result + except Exception as e: + error_msg = str(e) + logger.exception(f"Error sending email messages: {error_msg}") + + # Log more details about the error + if hasattr(self._backend, 'host'): + logger.error(f"SMTP Host: {self._backend.host}, Port: {getattr(self._backend, 'port', 'N/A')}") + + if not self.fail_silently: + raise + return 0 + logger.warning("No email backend available, cannot send messages") + return 0 + diff --git a/reports/forms.py b/reports/forms.py new file mode 100644 index 0000000..74e6410 --- /dev/null +++ b/reports/forms.py @@ -0,0 +1,124 @@ +""" +Forms for reports app. +""" +from django import forms +from accounts.form_mixins import BotProtectionMixin, BrowserFingerprintMixin, RateLimitMixin +from .models import ScamReport, TakedownRequest + + +class ScamReportForm(RateLimitMixin, forms.ModelForm): + """Form for creating/editing scam reports.""" + class Meta: + model = ScamReport + fields = [ + 'title', 'description', 'scam_type', + 'reported_url', 'reported_email', 'reported_phone', 'reported_company', + 'tags', 'is_anonymous' + ] + widgets = { + 'title': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 8}), + 'scam_type': forms.Select(attrs={'class': 'form-control'}), + 'reported_url': forms.URLInput(attrs={'class': 'form-control'}), + 'reported_email': forms.EmailInput(attrs={'class': 'form-control'}), + 'reported_phone': forms.TextInput(attrs={'class': 'form-control'}), + 'reported_company': forms.TextInput(attrs={'class': 'form-control'}), + 'tags': forms.SelectMultiple(attrs={'class': 'form-control'}), + 'is_anonymous': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + + +class ContactForm(BotProtectionMixin, BrowserFingerprintMixin, forms.Form): + """Contact form for users to reach out.""" + name = forms.CharField( + max_length=200, + required=True, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Вашето име' + }), + label='Име *' + ) + email = forms.EmailField( + required=True, + widget=forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'your.email@example.com' + }), + label='Имейл *' + ) + subject = forms.CharField( + max_length=200, + required=True, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Тема на съобщението' + }), + label='Тема *' + ) + message = forms.CharField( + required=True, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 8, + 'placeholder': 'Вашето съобщение...' + }), + label='Съобщение *' + ) + inquiry_type = forms.ChoiceField( + choices=[ + ('general', 'Общ въпрос'), + ('report_issue', 'Проблем с доклад'), + ('technical', 'Техническа поддръжка'), + ('feedback', 'Обратна връзка'), + ('other', 'Друго'), + ], + required=True, + widget=forms.Select(attrs={ + 'class': 'form-control' + }), + label='Тип заявка *' + ) + + +class TakedownRequestForm(BotProtectionMixin, BrowserFingerprintMixin, forms.ModelForm): + """Form for requesting takedown of a scam report.""" + + class Meta: + model = TakedownRequest + fields = ['requester_name', 'requester_email', 'requester_phone', 'reason', 'evidence'] + widgets = { + 'requester_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Вашето име' + }), + 'requester_email': forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'your.email@example.com' + }), + 'requester_phone': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': '+359 XXX XXX XXX (незадължително)' + }), + 'reason': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 6, + 'placeholder': 'Обяснете защо смятате, че докладът трябва да бъде премахнат...' + }), + 'evidence': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 6, + 'placeholder': 'Предоставете доказателства или допълнителна информация (незадължително)...' + }), + } + labels = { + 'requester_name': 'Име *', + 'requester_email': 'Имейл *', + 'requester_phone': 'Телефон', + 'reason': 'Причина за заявката *', + 'evidence': 'Доказателства / Допълнителна информация', + } + help_texts = { + 'reason': 'Моля, обяснете подробно защо смятате, че информацията в доклада е невярна или несправедлива.', + 'evidence': 'Ако имате документи, снимки или друга информация, която подкрепя вашата заявка, моля опишете я тук.', + } diff --git a/reports/migrations/0001_initial.py b/reports/migrations/0001_initial.py new file mode 100644 index 0000000..af34d5b --- /dev/null +++ b/reports/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.2.8 on 2025-11-26 13:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ScamTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(blank=True, max_length=100, unique=True)), + ('description', models.TextField(blank=True)), + ('color', models.CharField(default='#007bff', help_text='Hex color code for display', max_length=7)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Scam Tag', + 'verbose_name_plural': 'Scam Tags', + 'db_table': 'reports_scamtag', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ScamReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_anonymous', models.BooleanField(default=False, help_text='Report submitted anonymously')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField()), + ('scam_type', models.CharField(choices=[('phishing', 'Phishing'), ('fake_website', 'Fake Website'), ('romance_scam', 'Romance Scam'), ('investment_scam', 'Investment Scam'), ('tech_support_scam', 'Tech Support Scam'), ('identity_theft', 'Identity Theft'), ('fake_product', 'Fake Product'), ('advance_fee', 'Advance Fee Fraud'), ('other', 'Other')], default='other', max_length=50)), + ('reported_url', models.URLField(blank=True, max_length=500, null=True)), + ('reported_email', models.EmailField(blank=True, max_length=254, null=True)), + ('reported_phone', models.CharField(blank=True, max_length=20, null=True)), + ('reported_company', models.CharField(blank=True, max_length=200, null=True)), + ('evidence_files', models.JSONField(blank=True, default=list, help_text='List of file paths for evidence')), + ('status', models.CharField(choices=[('pending', 'Pending Review'), ('under_review', 'Under Review'), ('verified', 'Verified'), ('rejected', 'Rejected'), ('archived', 'Archived')], default='pending', max_length=20)), + ('verification_score', models.IntegerField(default=0, help_text='OSINT verification confidence score (0-100)')), + ('is_public', models.BooleanField(default=True, help_text='Visible in public database')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('reporter_ip', models.GenericIPAddressField(blank=True, null=True)), + ('reporter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to=settings.AUTH_USER_MODEL)), + ('tags', models.ManyToManyField(blank=True, related_name='reports', to='reports.scamtag')), + ], + options={ + 'verbose_name': 'Scam Report', + 'verbose_name_plural': 'Scam Reports', + 'db_table': 'reports_scamreport', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ScamVerification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('verification_method', models.CharField(choices=[('whois', 'WHOIS Lookup'), ('dns', 'DNS Records'), ('ssl', 'SSL Certificate'), ('archive', 'Wayback Machine'), ('email_check', 'Email Validation'), ('phone_check', 'Phone Validation'), ('business_registry', 'Business Registry'), ('social_media', 'Social Media'), ('manual', 'Manual Review')], max_length=50)), + ('verification_data', models.JSONField(default=dict, help_text='Raw verification data')), + ('confidence_score', models.IntegerField(default=0, help_text='Confidence score for this verification (0-100)')), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verifications', to='reports.scamreport')), + ('verified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Scam Verification', + 'verbose_name_plural': 'Scam Verifications', + 'db_table': 'reports_scamverification', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='scamreport', + index=models.Index(fields=['status', 'created_at'], name='reports_sca_status_91c8ad_idx'), + ), + migrations.AddIndex( + model_name='scamreport', + index=models.Index(fields=['scam_type', 'status'], name='reports_sca_scam_ty_fd12f9_idx'), + ), + migrations.AddIndex( + model_name='scamreport', + index=models.Index(fields=['reported_url'], name='reports_sca_reporte_ebc596_idx'), + ), + migrations.AddIndex( + model_name='scamreport', + index=models.Index(fields=['reported_email'], name='reports_sca_reporte_c31241_idx'), + ), + migrations.AddIndex( + model_name='scamreport', + index=models.Index(fields=['reported_phone'], name='reports_sca_reporte_33869d_idx'), + ), + ] diff --git a/reports/migrations/0002_scamreport_is_auto_discovered.py b/reports/migrations/0002_scamreport_is_auto_discovered.py new file mode 100644 index 0000000..621448d --- /dev/null +++ b/reports/migrations/0002_scamreport_is_auto_discovered.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-11-26 18:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='scamreport', + name='is_auto_discovered', + field=models.BooleanField(default=False, help_text='Automatically discovered by OSINT system'), + ), + ] diff --git a/reports/migrations/0003_sitesettings.py b/reports/migrations/0003_sitesettings.py new file mode 100644 index 0000000..267826f --- /dev/null +++ b/reports/migrations/0003_sitesettings.py @@ -0,0 +1,29 @@ +# Generated manually + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0002_scamreport_is_auto_discovered'), + ] + + operations = [ + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('contact_email', models.EmailField(default='support@fraudplatform.bg', help_text='Основен имейл за контакти и поддръжка', max_length=254)), + ('contact_phone', models.CharField(default='+359 2 XXX XXXX', help_text='Телефонен номер за контакти', max_length=50)), + ('contact_address', models.CharField(blank=True, default='София, България', help_text='Адрес за контакти', max_length=200)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Настройки на Сайта', + 'verbose_name_plural': 'Настройки на Сайта', + 'db_table': 'reports_sitesettings', + }, + ), + ] + diff --git a/reports/migrations/0004_alter_sitesettings_contact_address_and_more.py b/reports/migrations/0004_alter_sitesettings_contact_address_and_more.py new file mode 100644 index 0000000..d7be58c --- /dev/null +++ b/reports/migrations/0004_alter_sitesettings_contact_address_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-11-26 19:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0003_sitesettings'), + ] + + operations = [ + migrations.AlterField( + model_name='sitesettings', + name='contact_address', + field=models.CharField(blank=True, default='', help_text='Адрес за контакти (незадължително)', max_length=200), + ), + migrations.AlterField( + model_name='sitesettings', + name='contact_phone', + field=models.CharField(blank=True, default='', help_text='Телефонен номер за контакти (незадължително)', max_length=50), + ), + ] diff --git a/reports/migrations/0005_takedownrequest.py b/reports/migrations/0005_takedownrequest.py new file mode 100644 index 0000000..d660d1b --- /dev/null +++ b/reports/migrations/0005_takedownrequest.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.8 on 2025-11-26 19:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0004_alter_sitesettings_contact_address_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TakedownRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('requester_name', models.CharField(help_text='Име на заявителя', max_length=200)), + ('requester_email', models.EmailField(help_text='Имейл на заявителя', max_length=254)), + ('requester_phone', models.CharField(blank=True, help_text='Телефон на заявителя (незадължително)', max_length=50)), + ('reason', models.TextField(help_text='Причина за заявката за премахване')), + ('evidence', models.TextField(blank=True, help_text='Доказателства или допълнителна информация')), + ('status', models.CharField(choices=[('pending', 'Pending Review'), ('under_review', 'Under Review'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('review_notes', models.TextField(blank=True, help_text='Бележки от модератора')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='takedown_requests', to='reports.scamreport')), + ('reviewed_by', models.ForeignKey(blank=True, limit_choices_to={'role__in': ['moderator', 'admin']}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_takedown_requests', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Заявка за Премахване', + 'verbose_name_plural': 'Заявки за Премахване', + 'db_table': 'reports_takedownrequest', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['report', 'status'], name='reports_tak_report__a40ed0_idx'), models.Index(fields=['status', 'created_at'], name='reports_tak_status_049c16_idx')], + }, + ), + ] diff --git a/reports/migrations/0006_sitesettings_default_from_email_and_more.py b/reports/migrations/0006_sitesettings_default_from_email_and_more.py new file mode 100644 index 0000000..cc73ccc --- /dev/null +++ b/reports/migrations/0006_sitesettings_default_from_email_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.8 on 2025-11-26 19:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0005_takedownrequest'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='default_from_email', + field=models.EmailField(default='noreply@fraudplatform.bg', help_text='Имейл адрес по подразбиране за изпращане', max_length=254), + ), + migrations.AddField( + model_name='sitesettings', + name='email_backend', + field=models.CharField(choices=[('django.core.mail.backends.smtp.EmailBackend', 'SMTP'), ('django.core.mail.backends.console.EmailBackend', 'Console (Development)'), ('django.core.mail.backends.filebased.EmailBackend', 'File Based')], default='django.core.mail.backends.smtp.EmailBackend', help_text='Тип на имейл сървъра', max_length=100), + ), + migrations.AddField( + model_name='sitesettings', + name='email_host', + field=models.CharField(blank=True, default='', help_text='SMTP сървър (напр. smtp.gmail.com)', max_length=255), + ), + migrations.AddField( + model_name='sitesettings', + name='email_host_password', + field=models.CharField(blank=True, default='', help_text='SMTP парола (ще бъде криптирана)', max_length=255), + ), + migrations.AddField( + model_name='sitesettings', + name='email_host_user', + field=models.CharField(blank=True, default='', help_text='SMTP потребителско име / имейл', max_length=255), + ), + migrations.AddField( + model_name='sitesettings', + name='email_port', + field=models.IntegerField(default=587, help_text='SMTP порт (обикновено 587 за TLS или 465 за SSL)'), + ), + migrations.AddField( + model_name='sitesettings', + name='email_timeout', + field=models.IntegerField(default=10, help_text='Таймаут за имейл връзка (секунди)'), + ), + migrations.AddField( + model_name='sitesettings', + name='email_use_ssl', + field=models.BooleanField(default=False, help_text='Използване на SSL (за порт 465)'), + ), + migrations.AddField( + model_name='sitesettings', + name='email_use_tls', + field=models.BooleanField(default=True, help_text='Използване на TLS (за порт 587)'), + ), + ] diff --git a/reports/migrations/__init__.py b/reports/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports/models.py b/reports/models.py new file mode 100644 index 0000000..f33a705 --- /dev/null +++ b/reports/models.py @@ -0,0 +1,411 @@ +""" +Scam and fraud report models. +""" +from django.db import models +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils.text import slugify +from django.core.cache import cache +from accounts.security import DataEncryption + +User = get_user_model() + + +class SiteSettings(models.Model): + """ + Site-wide settings that can be managed from admin. + Uses singleton pattern - only one instance should exist. + """ + contact_email = models.EmailField( + default='support@fraudplatform.bg', + help_text='Основен имейл за контакти и поддръжка' + ) + contact_phone = models.CharField( + max_length=50, + blank=True, + default='', + help_text='Телефонен номер за контакти (незадължително)' + ) + contact_address = models.CharField( + max_length=200, + blank=True, + default='', + help_text='Адрес за контакти (незадължително)' + ) + + # Email Server Settings + email_backend = models.CharField( + max_length=100, + default='django.core.mail.backends.smtp.EmailBackend', + choices=[ + ('django.core.mail.backends.smtp.EmailBackend', 'SMTP'), + ('django.core.mail.backends.console.EmailBackend', 'Console (Development)'), + ('django.core.mail.backends.filebased.EmailBackend', 'File Based'), + ], + help_text='Тип на имейл сървъра' + ) + email_host = models.CharField( + max_length=255, + blank=True, + default='', + help_text='SMTP сървър (напр. smtp.gmail.com)' + ) + email_port = models.IntegerField( + default=587, + help_text='SMTP порт (обикновено 587 за TLS или 465 за SSL)' + ) + email_use_tls = models.BooleanField( + default=True, + help_text='Използване на TLS (за порт 587)' + ) + email_use_ssl = models.BooleanField( + default=False, + help_text='Използване на SSL (за порт 465)' + ) + email_host_user = models.CharField( + max_length=255, + blank=True, + default='', + help_text='SMTP потребителско име / имейл' + ) + email_host_password = models.CharField( + max_length=255, + blank=True, + default='', + help_text='SMTP парола (ще бъде криптирана)' + ) + default_from_email = models.EmailField( + default='noreply@fraudplatform.bg', + help_text='Имейл адрес по подразбиране за изпращане' + ) + email_timeout = models.IntegerField( + default=10, + help_text='Таймаут за имейл връзка (секунди)' + ) + + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Настройки на Сайта' + verbose_name_plural = 'Настройки на Сайта' + db_table = 'reports_sitesettings' + + def __str__(self): + return 'Настройки на Сайта' + + def save(self, *args, **kwargs): + # Ensure only one instance exists + self.pk = 1 + + # Encrypt email password if it's provided and not already encrypted + if self.email_host_password: + # Check if it's already encrypted by trying to decrypt it + # If decryption succeeds, it's already encrypted, so keep original + # If decryption fails, it's plain text, so encrypt it + is_encrypted = False + try: + # Try to decrypt - if it succeeds, it's already encrypted + DataEncryption.decrypt(self.email_host_password) + is_encrypted = True + except (Exception, ValueError, TypeError): + # Decryption failed, so it's plain text + is_encrypted = False + + # Only encrypt if it's not already encrypted + if not is_encrypted: + self.email_host_password = DataEncryption.encrypt(self.email_host_password) + + super().save(*args, **kwargs) + # Clear cache when settings are updated + cache.delete('site_settings') + + def get_email_password(self): + """Get decrypted email password.""" + if not self.email_host_password: + return '' + try: + return DataEncryption.decrypt(self.email_host_password) + except: + # If decryption fails, return as-is (might be plain text from migration) + return self.email_host_password + + def delete(self, *args, **kwargs): + # Prevent deletion - settings should always exist + pass + + @classmethod + def get_settings(cls): + """Get site settings with caching.""" + settings = cache.get('site_settings') + if settings is None: + settings, created = cls.objects.get_or_create(pk=1) + cache.set('site_settings', settings, 3600) # Cache for 1 hour + return settings + + +class ScamTag(models.Model): + """ + Tags for categorizing scam reports. + """ + name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(max_length=100, unique=True, blank=True) + description = models.TextField(blank=True) + color = models.CharField( + max_length=7, + default='#007bff', + help_text='Hex color code for display' + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'reports_scamtag' + verbose_name = 'Scam Tag' + verbose_name_plural = 'Scam Tags' + ordering = ['name'] + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + def __str__(self): + return self.name + + +class ScamReport(models.Model): + """ + Main scam/fraud report model. + """ + SCAM_TYPE_CHOICES = [ + ('phishing', 'Phishing'), + ('fake_website', 'Fake Website'), + ('romance_scam', 'Romance Scam'), + ('investment_scam', 'Investment Scam'), + ('tech_support_scam', 'Tech Support Scam'), + ('identity_theft', 'Identity Theft'), + ('fake_product', 'Fake Product'), + ('advance_fee', 'Advance Fee Fraud'), + ('other', 'Other'), + ] + + STATUS_CHOICES = [ + ('pending', 'Pending Review'), + ('under_review', 'Under Review'), + ('verified', 'Verified'), + ('rejected', 'Rejected'), + ('archived', 'Archived'), + ] + + # Reporter information + reporter = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reports' + ) + is_anonymous = models.BooleanField( + default=False, + help_text='Report submitted anonymously' + ) + + # Report details + title = models.CharField(max_length=200) + description = models.TextField() + scam_type = models.CharField( + max_length=50, + choices=SCAM_TYPE_CHOICES, + default='other' + ) + + # Reported entities + reported_url = models.URLField(blank=True, null=True, max_length=500) + reported_email = models.EmailField(blank=True, null=True) + reported_phone = models.CharField(max_length=20, blank=True, null=True) + reported_company = models.CharField(max_length=200, blank=True, null=True) + + # Evidence + evidence_files = models.JSONField( + default=list, + blank=True, + help_text='List of file paths for evidence' + ) + + # Status and verification + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending' + ) + verification_score = models.IntegerField( + default=0, + help_text='OSINT verification confidence score (0-100)' + ) + + # Visibility + is_public = models.BooleanField( + default=True, + help_text='Visible in public database' + ) + is_auto_discovered = models.BooleanField( + default=False, + help_text='Automatically discovered by OSINT system' + ) + + # Metadata + tags = models.ManyToManyField(ScamTag, blank=True, related_name='reports') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + verified_at = models.DateTimeField(null=True, blank=True) + + # IP tracking for anonymous reports + reporter_ip = models.GenericIPAddressField(null=True, blank=True) + + class Meta: + db_table = 'reports_scamreport' + verbose_name = 'Scam Report' + verbose_name_plural = 'Scam Reports' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['status', 'created_at']), + models.Index(fields=['scam_type', 'status']), + models.Index(fields=['reported_url']), + models.Index(fields=['reported_email']), + models.Index(fields=['reported_phone']), + ] + + def __str__(self): + return f"{self.title} - {self.get_status_display()}" + + def get_absolute_url(self): + return reverse('reports:detail', kwargs={'pk': self.pk}) + + def get_reporter_display(self): + if self.is_anonymous: + return "Anonymous" + return self.reporter.username if self.reporter else "Unknown" + + +class ScamVerification(models.Model): + """ + OSINT verification data for scam reports. + """ + VERIFICATION_METHOD_CHOICES = [ + ('whois', 'WHOIS Lookup'), + ('dns', 'DNS Records'), + ('ssl', 'SSL Certificate'), + ('archive', 'Wayback Machine'), + ('email_check', 'Email Validation'), + ('phone_check', 'Phone Validation'), + ('business_registry', 'Business Registry'), + ('social_media', 'Social Media'), + ('manual', 'Manual Review'), + ] + + report = models.ForeignKey( + ScamReport, + on_delete=models.CASCADE, + related_name='verifications' + ) + verification_method = models.CharField( + max_length=50, + choices=VERIFICATION_METHOD_CHOICES + ) + verification_data = models.JSONField( + default=dict, + help_text='Raw verification data' + ) + confidence_score = models.IntegerField( + default=0, + help_text='Confidence score for this verification (0-100)' + ) + verified_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='verifications' + ) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'reports_scamverification' + verbose_name = 'Scam Verification' + verbose_name_plural = 'Scam Verifications' + ordering = ['-created_at'] + + def __str__(self): + return f"Verification for {self.report.title} via {self.get_verification_method_display()}" + + +class TakedownRequest(models.Model): + """ + Request to take down a scam report by the accused party. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Review'), + ('under_review', 'Under Review'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ] + + report = models.ForeignKey( + ScamReport, + on_delete=models.CASCADE, + related_name='takedown_requests' + ) + requester_name = models.CharField( + max_length=200, + help_text='Име на заявителя' + ) + requester_email = models.EmailField( + help_text='Имейл на заявителя' + ) + requester_phone = models.CharField( + max_length=50, + blank=True, + help_text='Телефон на заявителя (незадължително)' + ) + reason = models.TextField( + help_text='Причина за заявката за премахване' + ) + evidence = models.TextField( + blank=True, + help_text='Доказателства или допълнителна информация' + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending' + ) + reviewed_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reviewed_takedown_requests', + limit_choices_to={'role__in': ['moderator', 'admin']} + ) + review_notes = models.TextField( + blank=True, + help_text='Бележки от модератора' + ) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = 'reports_takedownrequest' + verbose_name = 'Заявка за Премахване' + verbose_name_plural = 'Заявки за Премахване' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['report', 'status']), + models.Index(fields=['status', 'created_at']), + ] + + def __str__(self): + return f"Takedown request for {self.report.title} by {self.requester_name}" diff --git a/reports/tests.py b/reports/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/reports/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/reports/urls.py b/reports/urls.py new file mode 100644 index 0000000..5510849 --- /dev/null +++ b/reports/urls.py @@ -0,0 +1,33 @@ +""" +URL configuration for reports app. +""" +from django.urls import path +from django.views.generic import TemplateView +from . import views + +app_name = 'reports' + +urlpatterns = [ + # Home page + path('', views.HomeView.as_view(), name='home'), + + # Public views + path('reports/', views.ReportListView.as_view(), name='list'), + path('reports//', views.ReportDetailView.as_view(), name='detail'), + path('create/', views.ReportCreateView.as_view(), name='create'), + + # User views + path('my-reports/', views.MyReportsView.as_view(), name='my_reports'), + path('reports//edit/', views.ReportEditView.as_view(), name='edit'), + path('reports//delete/', views.ReportDeleteView.as_view(), name='delete'), + + # Search + path('search/', views.ReportSearchView.as_view(), name='search'), + + # Contact + path('contact/', views.ContactView.as_view(), name='contact'), + + # Takedown request + path('reports//takedown/', views.TakedownRequestView.as_view(), name='takedown_request'), +] + diff --git a/reports/views.py b/reports/views.py new file mode 100644 index 0000000..ffc3e54 --- /dev/null +++ b/reports/views.py @@ -0,0 +1,454 @@ +""" +Views for reports app. +""" +from django.shortcuts import render, get_object_or_404, redirect +from django.views.generic import TemplateView, ListView, DetailView, CreateView, UpdateView, DeleteView, FormView +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.db.models import Q, Count +from django.core.exceptions import ValidationError +from accounts.security import InputSanitizer +from .models import ScamReport, ScamTag, TakedownRequest +from .forms import ScamReportForm, ContactForm, TakedownRequestForm +from django.contrib import messages +from django.core.mail import send_mail +from django.conf import settings + +# Bulgarian translations for scam types +SCAM_TYPE_BG = { + 'phishing': 'Фишинг', + 'fake_website': 'Фалшив Уебсайт', + 'romance_scam': 'Романтична Измама', + 'investment_scam': 'Инвестиционна Измама', + 'tech_support_scam': 'Техническа Поддръжка Измама', + 'identity_theft': 'Кражба на Личност', + 'fake_product': 'Фалшив Продукт', + 'advance_fee': 'Авансово Плащане', + 'other': 'Друго', +} + + +class HomeView(TemplateView): + """Home page view.""" + template_name = 'reports/home.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['total_reports'] = ScamReport.objects.filter(status='verified').count() + context['recent_reports'] = ScamReport.objects.filter( + is_public=True, + status='verified' + ).order_by('-created_at')[:5] + + # Get scam types with display names - ALL types, not just top 5 + scam_types_data = ScamReport.objects.filter( + status='verified' + ).values('scam_type').annotate( + count=Count('id') + ).order_by('-count') + + # Add display names with Bulgarian translations + scam_types_list = [] + total_verified = context['total_reports'] or 1 # Avoid division by zero + for item in scam_types_data: + scam_type_key = item['scam_type'] + display_name = SCAM_TYPE_BG.get(scam_type_key, dict(ScamReport.SCAM_TYPE_CHOICES).get(scam_type_key, scam_type_key)) + percentage = (item['count'] / total_verified * 100) if total_verified > 0 else 0 + scam_types_list.append({ + 'scam_type': scam_type_key, + 'display_name': display_name, + 'count': item['count'], + 'percentage': round(percentage, 1) + }) + + context['scam_types'] = scam_types_list + + # SEO metadata + self.request.seo_title = 'Портал за Докладване на Измами - България' + self.request.seo_description = f'Портал за докладване на измами. Над {context["total_reports"]} верифицирани доклада. Защита на гражданите от онлайн измами, фишинг, фалшиви уебсайтове и киберпрестъпления.' + self.request.seo_keywords = 'измами, докладване измами, киберпрестъпления, фишинг, фалшив уебсайт, защита потребители, България, портал за докладване на измами, анти-измами' + self.request.canonical_url = self.request.build_absolute_uri('/') + + return context + + +class ReportListView(ListView): + """List all public verified reports.""" + model = ScamReport + template_name = 'reports/list.html' + context_object_name = 'reports' + paginate_by = 20 + + def get_queryset(self): + return ScamReport.objects.filter( + is_public=True, + status='verified' + ).select_related('reporter').prefetch_related('tags') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # SEO metadata + self.request.seo_title = 'Всички Доклади за Измами - Портал за Докладване на Измами' + self.request.seo_description = 'Прегледайте всички верифицирани доклади за измами в България. Търсете по вид измама, дата и ключови думи.' + self.request.seo_keywords = 'доклади измами, списък измами, верифицирани доклади, измами България' + self.request.canonical_url = self.request.build_absolute_uri('/reports/') + return context + + +class ReportDetailView(DetailView): + """View a single report.""" + model = ScamReport + template_name = 'reports/detail.html' + context_object_name = 'report' + + def get_queryset(self): + # Allow viewing if public or user is owner/moderator + queryset = ScamReport.objects.all() + if not self.request.user.is_authenticated: + queryset = queryset.filter(is_public=True, status='verified') + elif not self.request.user.is_moderator(): + queryset = queryset.filter( + Q(is_public=True, status='verified') | Q(reporter=self.request.user) + ) + return queryset.select_related('reporter').prefetch_related('tags', 'verifications', 'moderation_actions') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + report = context['report'] + + # SEO metadata + scam_type_display = SCAM_TYPE_BG.get(report.scam_type, report.get_scam_type_display()) + self.request.seo_title = f'{report.title} - Доклад за Измама' + self.request.seo_description = f'Доклад за {scam_type_display.lower()}: {report.description[:150]}...' if len(report.description) > 150 else report.description + self.request.seo_keywords = f'измама, {scam_type_display.lower()}, доклад, {", ".join([tag.name for tag in report.tags.all()[:5]])}' + self.request.seo_type = 'article' + self.request.canonical_url = self.request.build_absolute_uri(report.get_absolute_url()) + + return context + + +class ReportCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + """Create a new scam report.""" + model = ScamReport + form_class = ScamReportForm + template_name = 'reports/create.html' + success_url = reverse_lazy('reports:my_reports') + success_message = "Report submitted successfully! It will be reviewed by moderators." + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # SEO metadata + self.request.seo_title = 'Докладване на Измама - Портал за Докладване на Измами' + self.request.seo_description = 'Докладвайте измама. Помогнете да защитим другите граждани от онлайн измами и киберпрестъпления.' + self.request.seo_keywords = 'докладване измама, сигнализиране измама, докладване онлайн измама' + self.request.canonical_url = self.request.build_absolute_uri('/create/') + self.request.meta_robots = 'noindex, nofollow' # Don't index form pages + return context + + def get_form_kwargs(self): + """Pass request to form for rate limiting.""" + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def form_valid(self, form): + # Sanitize user input + if form.cleaned_data.get('title'): + form.cleaned_data['title'] = InputSanitizer.sanitize_html(form.cleaned_data['title']) + if form.cleaned_data.get('description'): + form.cleaned_data['description'] = InputSanitizer.sanitize_html(form.cleaned_data['description']) + + # Validate URLs + if form.cleaned_data.get('reported_url'): + if not InputSanitizer.validate_url(form.cleaned_data['reported_url']): + form.add_error('reported_url', 'Invalid URL format') + return self.form_invalid(form) + + # Validate email + if form.cleaned_data.get('reported_email'): + if not InputSanitizer.validate_email(form.cleaned_data['reported_email']): + form.add_error('reported_email', 'Invalid email format') + return self.form_invalid(form) + + form.instance.reporter = self.request.user + form.instance.reporter_ip = self.get_client_ip() + response = super().form_valid(form) + return response + + def get_client_ip(self): + x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = self.request.META.get('REMOTE_ADDR') + return ip + + +class MyReportsView(LoginRequiredMixin, ListView): + """List user's own reports.""" + model = ScamReport + template_name = 'reports/my_reports.html' + context_object_name = 'reports' + paginate_by = 20 + + def get_queryset(self): + return ScamReport.objects.filter( + reporter=self.request.user + ).prefetch_related('moderation_actions').order_by('-created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Get the first rejection action for each report + for report in context['reports']: + rejection_action = report.moderation_actions.filter(action_type='reject').first() + report.rejection_reason = rejection_action.reason if rejection_action else None + report.rejection_action = rejection_action + return context + + +class ReportEditView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + """Edit own report (only if pending or rejected).""" + model = ScamReport + form_class = ScamReportForm + template_name = 'reports/edit.html' + success_url = reverse_lazy('reports:my_reports') + success_message = "Report updated successfully!" + + def get_form_kwargs(self): + """Pass request to form for rate limiting.""" + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_queryset(self): + # Allow editing pending or rejected reports + return ScamReport.objects.filter( + reporter=self.request.user, + status__in=['pending', 'rejected'] + ) + + def form_valid(self, form): + # If editing a rejected report, change status back to pending for re-review + if form.instance.status == 'rejected': + form.instance.status = 'pending' + self.success_message = "Report updated and resubmitted for review!" + return super().form_valid(form) + + +class ReportDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + """Delete own report (only if pending or rejected).""" + model = ScamReport + template_name = 'reports/delete.html' + success_url = reverse_lazy('reports:my_reports') + success_message = "Report deleted successfully!" + + def get_queryset(self): + # Allow deleting pending or rejected reports + return ScamReport.objects.filter( + reporter=self.request.user, + status__in=['pending', 'rejected'] + ) + + +class ReportSearchView(ListView): + """Search reports.""" + model = ScamReport + template_name = 'reports/search.html' + context_object_name = 'reports' + paginate_by = 20 + + def get_queryset(self): + query = self.request.GET.get('q', '') + scam_type = self.request.GET.get('type', '') + + queryset = ScamReport.objects.filter( + is_public=True, + status='verified' + ) + + if query: + queryset = queryset.filter( + Q(title__icontains=query) | + Q(description__icontains=query) | + Q(reported_url__icontains=query) | + Q(reported_email__icontains=query) + ) + + if scam_type: + queryset = queryset.filter(scam_type=scam_type) + + return queryset.select_related('reporter').prefetch_related('tags') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['scam_type_choices'] = ScamReport.SCAM_TYPE_CHOICES + + # SEO metadata + query = self.request.GET.get('q', '') + if query: + self.request.seo_title = f'Търсене: {query} - Доклади за Измами' + self.request.seo_description = f'Резултати от търсенето за "{query}" в базата данни с доклади за измами.' + else: + self.request.seo_title = 'Търсене на Доклади - Официален Портал' + self.request.seo_description = 'Търсете в базата данни с верифицирани доклади за измами в България.' + self.request.seo_keywords = 'търсене измами, доклади, база данни измами' + self.request.canonical_url = self.request.build_absolute_uri('/search/') + + return context + + +class ContactView(SuccessMessageMixin, FormView): + """Contact us page.""" + form_class = ContactForm + template_name = 'reports/contact.html' + success_url = reverse_lazy('reports:contact') + success_message = "Благодарим ви! Вашето съобщение е изпратено успешно. Ще се свържем с вас скоро." + + def get_form_kwargs(self): + """Pass request to form for rate limiting.""" + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def form_valid(self, form): + # Send email notification (if email is configured) + try: + subject = f"[Контакт] {form.cleaned_data['subject']}" + message = f""" +Име: {form.cleaned_data['name']} +Имейл: {form.cleaned_data['email']} +Тип заявка: {form.cleaned_data['inquiry_type']} + +Съобщение: +{form.cleaned_data['message']} +""" + from .models import SiteSettings + site_settings = SiteSettings.get_settings() + from_email = site_settings.default_from_email + recipient_list = [site_settings.contact_email] + + send_mail( + subject, + message, + from_email, + recipient_list, + fail_silently=True, # Don't fail if email is not configured + ) + except Exception: + pass # Email sending is optional + + messages.success(self.request, self.success_message) + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Add contact information from SiteSettings (already in context via context processor) + # But we can add it explicitly if needed for backward compatibility + from .models import SiteSettings + site_settings = SiteSettings.get_settings() + context['contact_email'] = site_settings.contact_email + context['contact_phone'] = site_settings.contact_phone + context['contact_address'] = site_settings.contact_address + + # SEO metadata + self.request.seo_title = 'Контакти - Официален Портал за Докладване на Измами' + self.request.seo_description = 'Свържете се с нас за въпроси, обратна връзка или техническа поддръжка. Портал за докладване на измами в България.' + self.request.seo_keywords = 'контакти, поддръжка, обратна връзка, свържете се, официален портал' + self.request.canonical_url = self.request.build_absolute_uri('/contact/') + + return context + + +class TakedownRequestView(SuccessMessageMixin, FormView): + """View for requesting takedown of a scam report.""" + form_class = TakedownRequestForm + template_name = 'reports/takedown_request.html' + success_message = "Вашата заявка за премахване е изпратена успешно. Ще бъде прегледана от нашия екип в рамките на 2-5 работни дни." + + def dispatch(self, request, *args, **kwargs): + # Get the report + self.report = get_object_or_404( + ScamReport.objects.filter(is_public=True, status='verified'), + pk=kwargs['report_pk'] + ) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + """Pass request to form for rate limiting.""" + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['report'] = self.report + + # SEO metadata + self.request.seo_title = f'Заявка за Премахване - {self.report.title}' + self.request.seo_description = 'Заявка за премахване на доклад за измама' + self.request.canonical_url = self.request.build_absolute_uri() + self.request.meta_robots = 'noindex, nofollow' + + return context + + def form_valid(self, form): + # Get client IP + x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip_address = x_forwarded_for.split(',')[0] + else: + ip_address = self.request.META.get('REMOTE_ADDR') + + # Create takedown request + takedown_request = form.save(commit=False) + takedown_request.report = self.report + takedown_request.ip_address = ip_address + takedown_request.user_agent = self.request.META.get('HTTP_USER_AGENT', '') + takedown_request.save() + + # Send email notification (if email is configured) + try: + from .models import SiteSettings + + site_settings = SiteSettings.get_settings() + subject = f"[Заявка за Премахване] Доклад: {self.report.title}" + message = f""" +Нова заявка за премахване на доклад: + +Доклад: {self.report.title} (ID: {self.report.pk}) +URL: {self.request.build_absolute_uri(self.report.get_absolute_url())} + +Заявител: +Име: {form.cleaned_data['requester_name']} +Имейл: {form.cleaned_data['requester_email']} +Телефон: {form.cleaned_data.get('requester_phone', 'Не е предоставен')} + +Причина: +{form.cleaned_data['reason']} + +Доказателства: +{form.cleaned_data.get('evidence', 'Не са предоставени')} + +--- +IP адрес: {ip_address} +Дата: {takedown_request.created_at} +""" + from_email = site_settings.default_from_email + recipient_list = [site_settings.contact_email] + + send_mail( + subject, + message, + from_email, + recipient_list, + fail_silently=True, + ) + except Exception: + pass # Email sending is optional + + messages.success(self.request, self.success_message) + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('reports:detail', kwargs={'pk': self.report.pk}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..03ab9b4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +# Django and Core +Django>=5.2.8 +psycopg2-binary>=2.9.11 +python-decouple>=3.8 +django-environ>=0.12.0 + +# MFA/OTP +django-otp>=1.2.7 +qrcode>=7.4.2 +Pillow>=10.2.0 + +# Security +django-cors-headers>=4.3.1 +django-ratelimit>=4.1.0 +cryptography>=41.0.0 +argon2-cffi>=23.1.0 + +# File handling +Pillow>=10.2.0 + +# Task queue (for OSINT) +celery>=5.3.4 +redis>=5.0.1 + +# API clients (for OSINT) +requests>=2.31.0 +python-whois>=0.8.0 +dnspython>=2.4.2 +beautifulsoup4>=4.12.2 +lxml>=4.9.3 +urllib3>=2.0.7 + +# Utilities +python-dateutil>=2.8.2 + +# Development +django-extensions>=3.2.3 +ipython>=8.18.1 + +# Production (optional) +gunicorn>=21.2.0 +whitenoise>=6.6.0 + diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..6b3c82c --- /dev/null +++ b/templates/404.html @@ -0,0 +1,292 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Страницата не е намерена - 404{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
404
+ +
+ + + + + + + + + + + + + + + +
+ +

Страницата не е намерена

+ +

+ Съжаляваме, но страницата, която търсите, не съществува или е преместена. + Моля, проверете адреса или използвайте навигацията по-долу, за да намерите това, което търсите. +

+ + + + +
+
+{% endblock %} + diff --git a/templates/accounts/login.html b/templates/accounts/login.html new file mode 100644 index 0000000..be00f13 --- /dev/null +++ b/templates/accounts/login.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block title %}Вход - Официален Портал{% endblock %} + +{% block content %} +
+
+

Вход в Системата

+
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+ +
+
+{% endblock %} + diff --git a/templates/accounts/mfa_disable.html b/templates/accounts/mfa_disable.html new file mode 100644 index 0000000..43ccaed --- /dev/null +++ b/templates/accounts/mfa_disable.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} + +{% block title %}Деактивиране на Двуфакторна Автентификация - Официален Портал{% endblock %} + +{% block content %} +
+

Деактивиране на Двуфакторна Автентификация

+
+
+
+

Внимание: Деактивирането на ДА ще намали сигурността на вашия акаунт.

+

Сигурни ли сте, че искате да деактивирате двуфакторната автентификация?

+
+
+ +
+ {% csrf_token %} +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/accounts/mfa_enable.html b/templates/accounts/mfa_enable.html new file mode 100644 index 0000000..f3d29e7 --- /dev/null +++ b/templates/accounts/mfa_enable.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} + +{% block title %}Активиране на Двуфакторна Автентификация - Официален Портал{% endblock %} + +{% block content %} +
+
+

Активиране на Двуфакторна Автентификация

+

Въведете кода за потвърждение от вашето приложение за автентификация, за да активирате ДА.

+ +
+ {% csrf_token %} +
+ + + {% if form.token.errors %} +
{{ form.token.errors }}
+ {% endif %} + Въведете 6-цифрения код от вашето приложение за автентификация +
+ +
+ + +
+
+{% endblock %} + diff --git a/templates/accounts/mfa_setup.html b/templates/accounts/mfa_setup.html new file mode 100644 index 0000000..a497da6 --- /dev/null +++ b/templates/accounts/mfa_setup.html @@ -0,0 +1,81 @@ +{% extends 'base.html' %} + +{% block title %}Настройка на Двуфакторна Автентификация - Официален Портал{% endblock %} + +{% block content %} +
+

Настройка на Двуфакторна Автентификация

+ + {% if mfa_enabled %} +
+
+
+

Двуфакторната автентификация вече е активирана за вашия акаунт.

+ Деактивиране +
+
+ {% else %} +
+
+

Стъпка 1: Инсталиране на Приложение за Автентификация

+

Ако все още нямате такова, инсталирайте приложение за автентификация на вашето мобилно устройство:

+
    +
  • Google Authenticator (iOS, Android)
  • +
  • Microsoft Authenticator (iOS, Android)
  • +
  • Authy (iOS, Android)
  • +
  • 1Password (iOS, Android, Desktop)
  • +
+
+ +
+

Стъпка 2: Сканиране на QR Код

+

Отворете вашето приложение за автентификация и сканирайте този QR код:

+ {% if qr_code %} +
+ QR Code +
+
+

Или въведете този ключ ръчно:

+
{{ secret_key }}
+ +
+ {% else %} +

Генериране на QR код...

+ {% endif %} +
+ +
+

Стъпка 3: Потвърждаване на Настройката

+

Въведете 6-цифрения код от вашето приложение за автентификация, за да потвърдите настройката:

+ +
+
+ {% endif %} + + +
+ + +{% endblock %} + diff --git a/templates/accounts/mfa_verify.html b/templates/accounts/mfa_verify.html new file mode 100644 index 0000000..2281f9d --- /dev/null +++ b/templates/accounts/mfa_verify.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} + +{% block title %}Двуфакторна Автентификация - Официален Портал{% endblock %} + +{% block content %} +
+
+

Двуфакторна Автентификация

+

Моля, въведете кода за потвърждение от вашето приложение за автентификация, за да завършите входа.

+ +
+ {% csrf_token %} +
+ + + {% if form.token.errors %} +
{{ form.token.errors }}
+ {% endif %} + Въведете 6-цифрения код от вашето приложение за автентификация +
+ +
+ + +
+
+{% endblock %} + diff --git a/templates/accounts/password_change.html b/templates/accounts/password_change.html new file mode 100644 index 0000000..bb9feaa --- /dev/null +++ b/templates/accounts/password_change.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} + +{% block title %}Смяна на Парола - Официален Портал{% endblock %} + +{% block content %} +
+

Смяна на Парола

+
+ {% csrf_token %} +
+ + + {% if form.old_password.errors %} +
{{ form.old_password.errors }}
+ {% endif %} +
+
+ + + {% if form.new_password1.errors %} +
{{ form.new_password1.errors }}
+ {% endif %} + Минимум 12 символа +
+
+ + + {% if form.new_password2.errors %} +
{{ form.new_password2.errors }}
+ {% endif %} +
+
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/accounts/password_reset.html b/templates/accounts/password_reset.html new file mode 100644 index 0000000..551bfaa --- /dev/null +++ b/templates/accounts/password_reset.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block title %}Възстановяване на Парола - Официален Портал{% endblock %} + +{% block content %} +
+
+

Възстановяване на Парола

+

Въведете вашия имейл адрес и ще ви изпратим връзка за възстановяване на паролата.

+
+ {% csrf_token %} +
+ + + {% if form.email.errors %} +
{{ form.email.errors }}
+ {% endif %} +
+ +
+ +
+
+{% endblock %} + diff --git a/templates/accounts/password_reset_complete.html b/templates/accounts/password_reset_complete.html new file mode 100644 index 0000000..43f78fb --- /dev/null +++ b/templates/accounts/password_reset_complete.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} + +{% block title %}Възстановяване на Парола Завършено - Официален Портал{% endblock %} + +{% block content %} +
+
+

Възстановяване на Парола Завършено

+

Вашата парола е зададена. Можете да влезете в системата сега.

+ +
+
+{% endblock %} + diff --git a/templates/accounts/password_reset_confirm.html b/templates/accounts/password_reset_confirm.html new file mode 100644 index 0000000..8afc487 --- /dev/null +++ b/templates/accounts/password_reset_confirm.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} + +{% block title %}Задаване на Нова Парола - Официален Портал{% endblock %} + +{% block content %} +
+
+

Задаване на Нова Парола

+ {% if validlink %} +
+ {% csrf_token %} +
+ + + {% if form.new_password1.errors %} +
{{ form.new_password1.errors }}
+ {% endif %} + Минимум 12 символа +
+
+ + + {% if form.new_password2.errors %} +
{{ form.new_password2.errors }}
+ {% endif %} +
+ +
+ {% else %} +
+
+
+

Връзката за възстановяване на парола е невалидна или е изтекла. Моля, заявете нова.

+ Заявка за Нова Връзка +
+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/accounts/password_reset_done.html b/templates/accounts/password_reset_done.html new file mode 100644 index 0000000..b4e7151 --- /dev/null +++ b/templates/accounts/password_reset_done.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} + +{% block title %}Имейл за Възстановяване Изпратен - Официален Портал{% endblock %} + +{% block content %} +
+
+

Имейл за Възстановяване Изпратен

+

Изпратихме ви инструкции за задаване на вашата парола. Ако съществува акаунт с въведения имейл, трябва да ги получите скоро.

+

Ако не получите имейл, моля, уверете се, че сте въвели адреса, с който сте се регистрирали, и проверете папката за спам.

+ +
+
+{% endblock %} + diff --git a/templates/accounts/password_reset_email.html b/templates/accounts/password_reset_email.html new file mode 100644 index 0000000..bc96dfa --- /dev/null +++ b/templates/accounts/password_reset_email.html @@ -0,0 +1,22 @@ +{% load i18n %}{% autoescape off %} +Здравейте, + +Получихте този имейл, защото заявихте възстановяване на парола за вашия акаунт в {{ site_name|default:"Портал за Докладване на Измами" }}. + +Моля, кликнете на следната връзка, за да зададете нова парола: + +{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %} + +Ако връзката не работи, копирайте и поставете адреса в браузъра си. + +Ако не сте заявили възстановяване на парола, моля, игнорирайте този имейл. Вашата парола няма да бъде променена. + +Тази връзка е валидна за ограничен период от време за вашата сигурност. + +С уважение, +Екипът на {{ site_name|default:"Портал за Докладване на Измами" }} + +--- +Това е автоматично генериран имейл. Моля, не отговаряйте на този имейл. +{% endautoescape %} + diff --git a/templates/accounts/password_reset_email_subject.txt b/templates/accounts/password_reset_email_subject.txt new file mode 100644 index 0000000..950c844 --- /dev/null +++ b/templates/accounts/password_reset_email_subject.txt @@ -0,0 +1,2 @@ +Възстановяване на Парола - {{ site_name|default:"Портал за Докладване на Измами" }} + diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html new file mode 100644 index 0000000..333be5a --- /dev/null +++ b/templates/accounts/profile.html @@ -0,0 +1,96 @@ +{% extends 'base.html' %} + +{% block title %}Моят Профил - Официален Портал{% endblock %} + +{% block content %} +
+
+
+

Моят Профил

+ Редактиране на Профил +
+
+
+

Информация за Акаунта

+
+
+ Потребителско Име: + {{ user_obj.username }} +
+
+ Имейл: + {{ user_obj.email }} +
+
+ Роля: + {{ user_obj.get_role_display }} +
+
+ Потвърден Имейл: + {% if user_obj.is_verified %}Да{% else %}Не{% endif %} +
+
+ Двуфакторна Автентификация: + {% if user_obj.mfa_enabled %}Активирана{% else %}Деактивирана{% endif %} + {% if user_obj.mfa_enabled %} + Деактивиране + {% else %} + Активиране + {% endif %} +
+
+ Член от: + {{ user_obj.created_at|date:"d F Y" }} +
+
+
+ + {% if user_obj.profile %} +
+

Лична Информация

+
+ {% if user_obj.profile.first_name %} +
+ Име: + {{ user_obj.profile.first_name }} +
+ {% endif %} + {% if user_obj.profile.last_name %} +
+ Фамилия: + {{ user_obj.profile.last_name }} +
+ {% endif %} + {% if user_obj.profile.phone %} +
+ Телефон: + {{ user_obj.profile.phone }} +
+ {% endif %} +
+ Предпочитан Език: + {{ user_obj.profile.get_preferred_language_display }} +
+
+
+ {% endif %} +
+
+ + +
+{% endblock %} + diff --git a/templates/accounts/profile_edit.html b/templates/accounts/profile_edit.html new file mode 100644 index 0000000..c8c5ee7 --- /dev/null +++ b/templates/accounts/profile_edit.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} + +{% block title %}Редактиране на Профил - Официален Портал{% endblock %} + +{% block content %} +
+

Редактиране на Профил

+
+ {% csrf_token %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/accounts/register.html b/templates/accounts/register.html new file mode 100644 index 0000000..865e177 --- /dev/null +++ b/templates/accounts/register.html @@ -0,0 +1,57 @@ +{% extends 'base.html' %} + +{% block title %}Регистрация - Официален Портал{% endblock %} + +{% block content %} +
+
+

Създаване на Акаунт

+
+ {% csrf_token %} +
+ + + {% if form.username.errors %} +
{{ form.username.errors }}
+ {% endif %} +
+
+ + + {% if form.email.errors %} +
{{ form.email.errors }}
+ {% endif %} +
+
+ + + {% if form.password1.errors %} +
{{ form.password1.errors }}
+ {% endif %} + Минимум 12 символа +
+
+ + + {% if form.password2.errors %} +
{{ form.password2.errors }}
+ {% endif %} +
+
+ + {% if form.consent_given.errors %} +
{{ form.consent_given.errors }}
+ {% endif %} +
+ +
+ +
+
+{% endblock %} + diff --git a/templates/admin/reports/sitesettings/change_form.html b/templates/admin/reports/sitesettings/change_form.html new file mode 100644 index 0000000..c51015c --- /dev/null +++ b/templates/admin/reports/sitesettings/change_form.html @@ -0,0 +1,90 @@ +{% extends "admin/change_form.html" %} +{% load static %} + +{% block submit_buttons_bottom %} +{{ block.super }} + +{% if show_test_email %} +
+

Тест на Имейл Сървър

+

Изпратете тестов имейл, за да проверите дали настройките на имейл сървъра са правилни.

+
+ {% csrf_token %} +
+ + +
+
+ +
+
+

+ Забележка: Уверете се, че сте запазили промените преди изпращане на тестов имейл. +

+ {% if original.email_backend == 'django.core.mail.backends.smtp.EmailBackend' and not original.email_host %} +
+ ⚠️ Внимание: +

+ SMTP сървърът не е конфигуриран. Моля, попълнете полето "Email Host" и запазете настройките преди изпращане на тестов имейл. + Без конфигуриран SMTP сървър, имейлите ще се изпращат само в конзолата (за разработка). +

+
+ {% endif %} + {% if original.email_use_tls and original.email_use_ssl %} +
+ ❌ Грешка: +

+ TLS и SSL не могат да бъдат активирани едновременно! Моля, изберете само едно от двете. + Обикновено използвайте TLS за порт 587 или SSL за порт 465. +

+
+ {% endif %} +
+{% endif %} +{% endblock %} + +{% block admin_change_form_document_ready %} +{{ block.super }} + +{% endblock %} + diff --git a/templates/analytics/dashboard.html b/templates/analytics/dashboard.html new file mode 100644 index 0000000..a7b6705 --- /dev/null +++ b/templates/analytics/dashboard.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} + +{% block title %}Табло за Аналитика - Официален Портал{% endblock %} + +{% block content %} +
+
+

Табло за Аналитика

+
+
+
+
+

{{ total_reports|default:0 }}

+

Общо Доклади

+
+
+

{{ pending_reports|default:0 }}

+

Чакащи

+
+
+

{{ verified_reports|default:0 }}

+

Потвърдени

+
+
+

{{ rejected_reports|default:0 }}

+

Отхвърлени

+
+
+

{{ total_users|default:0 }}

+

Общо Потребители

+
+
+

{{ moderators|default:0 }}

+

Модератори

+
+
+
+
+ +{% if scam_types %} +
+
+

Разпределение по Видове Измами

+
+
+
+ {% for type in scam_types %} +
+
+ + {{ type.display_name }} + +
+ {{ type.count }} + доклад{{ type.count|pluralize:"а,а" }} +
+
+
+
+
+
+ {{ type.percentage }}% от общия брой +
+
+ {% endfor %} +
+
+
+{% endif %} + + +{% endblock %} + diff --git a/templates/analytics/reports.html b/templates/analytics/reports.html new file mode 100644 index 0000000..c66f092 --- /dev/null +++ b/templates/analytics/reports.html @@ -0,0 +1,283 @@ +{% extends 'base.html' %} + +{% block title %}Аналитика на Докладите - Официален Портал{% endblock %} + +{% block content %} + + + +
+
+

Обща Статистика

+
+
+
+
+

{{ total_reports|default:0 }}

+

Общо Доклади

+
+
+

{{ pending_reports|default:0 }}

+

В Очакване

+
+
+

{{ verified_reports|default:0 }}

+

Потвърдени

+
+
+

{{ rejected_reports|default:0 }}

+

Отхвърлени

+
+
+

{{ under_review_reports|default:0 }}

+

В Преглед

+
+
+
+
+ + +
+
+

Статистика по Период

+
+
+
+
+

{{ reports_last_7_days|default:0 }}

+

Последните 7 Дни

+
+
+

{{ reports_last_30_days|default:0 }}

+

Последните 30 Дни

+
+
+

{{ reports_last_90_days|default:0 }}

+

Последните 90 Дни

+
+
+
+
+ + +{% if scam_types %} +
+
+

Разпределение по Видове Измами

+
+
+
+ {% for type in scam_types %} +
+
+ + {{ type.display_name }} + +
+ {{ type.count }} + доклад{{ type.count|pluralize:"а,а" }} +
+
+
+
+
+
+ {{ type.percentage }}% от общия брой +
+
+ {% endfor %} +
+
+
+{% endif %} + + +
+
+

Статистика на Модерация

+
+
+
+
+

{{ total_moderations|default:0 }}

+

Общо Модерации

+
+
+

{{ approvals|default:0 }}

+

Одобрени

+
+
+

{{ rejections|default:0 }}

+

Отхвърлени

+
+
+ {% if avg_moderation_time_hours %} +
+

Време за Модерация

+
+
+

{{ avg_moderation_time_hours }}ч

+

Средно Време

+
+
+

{{ min_moderation_time_hours }}ч

+

Минимално Време

+
+
+

{{ max_moderation_time_hours }}ч

+

Максимално Време

+
+
+
+ {% endif %} +
+
+ + +{% if top_reporters %} +
+
+

Най-Активни Докладващи

+
+
+
+ + + + + + + + + + {% for reporter in top_reporters %} + + + + + + {% endfor %} + +
ПозицияПотребителБрой Доклади
{{ forloop.counter }}{{ reporter.reporter__username|default:"Анонимен" }}{{ reporter.report_count }}
+
+
+
+{% endif %} + + +{% if daily_reports %} +
+
+

Дневна Статистика (Последните 30 Дни)

+
+
+
+ +
+
+
+{% endif %} + + +{% if status_over_time %} +
+
+

Статуси по Време (Последните 7 Дни)

+
+
+
+ +
+
+
+{% endif %} + + + +{% if daily_reports %} + + +{% endif %} +{% endblock %} diff --git a/templates/analytics/users.html b/templates/analytics/users.html new file mode 100644 index 0000000..fa8f107 --- /dev/null +++ b/templates/analytics/users.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block title %}Аналитика на Потребителите - Официален Портал{% endblock %} + +{% block content %} +
+
+

Аналитика на Потребителите

+
+
+

Подробна аналитика на потребителите ще бъде показана тук.

+ +
+
+{% endblock %} + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..908ee99 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,291 @@ + + + + + + + + + {% block title %}{{ seo.full_title|default:"Портал за Докладване на Измами - България" }}{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if seo.twitter_site %}{% endif %} + {% if seo.twitter_creator %}{% endif %} + + + + + + + {% load static %} + + + + + + + + + + + + + + + + {% block extra_head %}{% endblock %} + {% block extra_css %}{% endblock %} + + + +
+ + +
+ + +
+
+ {% if messages %} +
+ {% for message in messages %} +
+
+ {% if message.tags == 'success' %}✓{% elif message.tags == 'error' %}✕{% else %}ℹ{% endif %} +
+
+ {{ message.tags|title }}: {{ message }} +
+
+ {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock %} +
+
+ + + + + + + + {% block extra_js %}{% endblock %} + + + + + + + diff --git a/templates/legal/data_request.html b/templates/legal/data_request.html new file mode 100644 index 0000000..b2066e4 --- /dev/null +++ b/templates/legal/data_request.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} + +{% block title %}Заявка за Данни - Официален Портал{% endblock %} + +{% block content %} +
+

GDPR Заявка за Данни

+

Съгласно GDPR, имате правото да получите достъп, да коригирате или да изтриете вашите лични данни. Изпратете заявка по-долу.

+ +
+ {% csrf_token %} +
+ + {{ form.request_type }} + {% if form.request_type.errors %} +
{{ form.request_type.errors }}
+ {% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} + Предоставете допълнителна информация за вашата заявка +
+ +
+ + Отказ +
+
+ +
+

Типове Заявки

+
    +
  • Достъп до Данни: Получете копие на всички ваши лични данни
  • +
  • Изтриване на Данни: Заявка за изтриване на вашите лични данни
  • +
  • Преносимост на Данни: Получете вашите данни в машиночетим формат
  • +
  • Коригиране на Данни: Заявка за корекция на неточни данни
  • +
  • Възражение: Възражение срещу обработката на вашите данни
  • +
  • Ограничаване: Заявка за ограничаване на обработката на данни
  • +
+
+
+{% endblock %} + diff --git a/templates/legal/data_request_detail.html b/templates/legal/data_request_detail.html new file mode 100644 index 0000000..4fe7336 --- /dev/null +++ b/templates/legal/data_request_detail.html @@ -0,0 +1,66 @@ +{% extends 'base.html' %} + +{% block title %}Статус на Заявка за Данни - Официален Портал{% endblock %} + +{% block content %} +
+
+

Статус на Заявка за Данни

+
+
+
+

Информация за Заявката

+
+
Тип Заявка: {{ data_request.get_request_type_display }}
+
Статус: {{ data_request.get_status_display }}
+
Изпратено: {{ data_request.requested_at|date:"d F Y, H:i" }}
+ {% if data_request.completed_at %} +
Завършено: {{ data_request.completed_at|date:"d F Y, H:i" }}
+ {% endif %} + {% if data_request.handled_by %} +
Обработено от: {{ data_request.handled_by.username }}
+ {% endif %} +
+
+ + {% if data_request.description %} +
+

Описание

+

{{ data_request.description|linebreaks }}

+
+ {% endif %} + + {% if data_request.response_file %} +
+

Отговор

+

Вашата заявка е завършена. Изтеглете файла с отговора:

+ Изтегляне на Отговор +
+ {% elif data_request.response_data %} +
+

Отговор

+
{{ data_request.response_data|safe }}
+
+ {% endif %} + + {% if data_request.status == 'pending' or data_request.status == 'in_progress' %} +
+
+
+
+

Вашата заявка се обработва. Ще бъдете уведомени, когато бъде завършена.

+
+
+
+ {% endif %} + +
+ Обратно към Профил + {% if data_request.status == 'pending' %} + Изпращане на Нова Заявка + {% endif %} +
+
+
+{% endblock %} + diff --git a/templates/legal/privacy_policy.html b/templates/legal/privacy_policy.html new file mode 100644 index 0000000..2a95807 --- /dev/null +++ b/templates/legal/privacy_policy.html @@ -0,0 +1,497 @@ +{% extends 'base.html' %} + +{% block title %}Политика за Поверителност - Официален Портал{% endblock %} + +{% block content %} + +{% endblock %} + diff --git a/templates/legal/terms_of_service.html b/templates/legal/terms_of_service.html new file mode 100644 index 0000000..859c5d6 --- /dev/null +++ b/templates/legal/terms_of_service.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} + +{% block title %}Условия за Ползване - Официален Портал{% endblock %} + +{% block content %} + +{% endblock %} + diff --git a/templates/moderation/approve.html b/templates/moderation/approve.html new file mode 100644 index 0000000..747cd4a --- /dev/null +++ b/templates/moderation/approve.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} + +{% block title %}Одобряване на Доклад - Официален Портал{% endblock %} + +{% block content %} +
+

Одобряване на Доклад

+
+
+
+

Сигурни ли сте, че искате да одобрите този доклад?

+

{{ object.title }}

+

Това ще маркира доклада като потвърден и ще го направи видим за обществеността.

+
+
+ +
+ {% csrf_token %} +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/moderation/dashboard.html b/templates/moderation/dashboard.html new file mode 100644 index 0000000..44cb9a9 --- /dev/null +++ b/templates/moderation/dashboard.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} + +{% block title %}Табло за Модерация - Официален Портал{% endblock %} + +{% block content %} +
+
+

Табло за Модерация

+
+
+
+
+

{{ pending_count|default:0 }}

+

Чакащи Доклади

+ Преглед на Опашката +
+
+

{{ under_review_count|default:0 }}

+

В Преглед

+
+
+

{{ verified_count|default:0 }}

+

Потвърдени Доклади

+
+
+

🤖 OSINT

+

Автоматични Доклади

+ Преглед на OSINT +
+
+
+
+ +
+
+

Последни Доклади

+
+
+ {% if reports %} +
+ {% for report in reports %} +
+
+

{{ report.title }}

+ {{ report.get_status_display }} +
+
+ {{ report.created_at|date:"d F Y, H:i" }} + {{ report.get_scam_type_display }} +
+ +
+ {% endfor %} +
+ {% else %} +
+

Няма доклади, чакащи преглед.

+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/moderation/queue.html b/templates/moderation/queue.html new file mode 100644 index 0000000..e623e70 --- /dev/null +++ b/templates/moderation/queue.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} + +{% block title %}Опашка за Модерация - Официален Портал{% endblock %} + +{% block content %} +
+
+

Опашка за Модерация

+
+
+ {% if queue_items %} +
+ {% for item in queue_items %} +
+
+

{{ item.report.title }}

+ {{ item.get_priority_display }} +
+
+ Изпратено: {{ item.report.created_at|date:"d F Y, H:i" }} + Вид: {{ item.report.get_scam_type_display }} + {% if item.assigned_to %} + Назначено на: {{ item.assigned_to.username }} + {% endif %} +
+ +
+ {% endfor %} +
+ + + {% else %} +
+

Опашката за модерация е празна. Всички доклади са прегледани.

+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/moderation/reject.html b/templates/moderation/reject.html new file mode 100644 index 0000000..5c57b9a --- /dev/null +++ b/templates/moderation/reject.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} + +{% block title %}Отхвърляне на Доклад - Официален Портал{% endblock %} + +{% block content %} +
+

Отхвърляне на Доклад

+
+
+
+

Сигурни ли сте, че искате да отхвърлите този доклад?

+

{{ object.title }}

+
+
+ +
+ {% csrf_token %} +
+ + + + Важно: Причината е задължителна и ще бъде показана на потребителя. Бъдете ясни и конструктивни, за да помогнете на потребителя да разбере какво трябва да подобри. + +
+
+ + + Тези бележки са само за модератори и администратори и няма да бъдат видими за автора на доклада. +
+
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/moderation/report_detail.html b/templates/moderation/report_detail.html new file mode 100644 index 0000000..e765fb6 --- /dev/null +++ b/templates/moderation/report_detail.html @@ -0,0 +1,103 @@ +{% extends 'base.html' %} + +{% block title %}Преглед на Доклад - Официален Портал{% endblock %} + +{% block content %} +
+
+

{{ report.title }}

+ +
+
+
+

Информация за Доклада

+
+
Статус: {{ report.get_status_display }}
+
Вид: {{ report.get_scam_type_display }}
+
Докладвано от: {{ report.get_reporter_display }}
+
Изпратено: {{ report.created_at|date:"d F Y, H:i" }}
+
+
+ +
+

Описание

+

{{ report.description|linebreaks }}

+
+ +
+

Докладвани Обекти

+
    + {% if report.reported_url %} +
  • + URL: {{ report.reported_url }} +
  • + {% endif %} + {% if report.reported_email %} +
  • + Имейл: {{ report.reported_email }} +
  • + {% endif %} + {% if report.reported_phone %} +
  • + Телефон: {{ report.reported_phone }} +
  • + {% endif %} + {% if report.reported_company %} +
  • + Фирма: {{ report.reported_company }} +
  • + {% endif %} +
+
+ + {% if osint_results %} +
+

OSINT Резултати

+ {% for result in osint_results %} +
+ {{ result.get_data_type_display }} ({{ result.source }}): + Увереност: {{ result.confidence_level }}% + {% if result.is_verified %} + Потвърдено + {% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if verifications %} +
+

Проверки

+ {% for verification in verifications %} +
+ {{ verification.get_verification_method_display }}: + Увереност: {{ verification.confidence_score }}% + {% if verification.notes %} +

{{ verification.notes }}

+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if moderation_actions %} +
+

История на Модерацията

+ {% for action in moderation_actions %} +
+ {{ action.get_action_type_display }} от {{ action.moderator.username }} + {{ action.created_at|date:"d F Y, H:i" }} + {% if action.reason %} +

Причина: {{ action.reason }}

+ {% endif %} +
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/osint/admin_dashboard.html b/templates/osint/admin_dashboard.html new file mode 100644 index 0000000..741676c --- /dev/null +++ b/templates/osint/admin_dashboard.html @@ -0,0 +1,299 @@ +{% extends 'base.html' %} + +{% block title %}OSINT Администраторски Табло{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + + +
+
+

📊 Seed Websites

+
{{ total_seeds }}
+

Общо: {{ total_seeds }} | Активни: {{ active_seeds }}

+ Управление +
+ +
+

🔍 Ключови Думи

+
{{ total_keywords }}
+

Общо: {{ total_keywords }} | Активни: {{ active_keywords }}

+ Управление +
+ +
+

📄 Събрано Съдържание

+
{{ total_crawled }}
+

Общо: {{ total_crawled }} | Потенциални измами: {{ potential_scams }}

+
+ +
+

📋 Автоматични Доклади

+
{{ pending_reports }}
+

Чакащи: {{ pending_reports }} | Публикувани: {{ published_reports }}

+ Преглед +
+
+ + +
+
+

🌐 Seed Websites

+ ➕ Добави Нов +
+
+ {% if seed_websites %} +
+ + + + + + + + + + + + + + + {% for seed in seed_websites %} + + + + + + + + + + + {% endfor %} + +
ИмеURLСтатусПриоритетПоследно CrawlСтранициСъвпаденияДействия
{{ seed.name }}{{ seed.url|truncatechars:40 }} + {% if seed.is_active %} + Активен + {% else %} + Неактивен + {% endif %} + {{ seed.get_priority_display }} + {% if seed.last_crawled_at %} + {{ seed.last_crawled_at|date:"d.m.Y H:i" }} + {% else %} + Никога + {% endif %} + {{ seed.pages_crawled }}{{ seed.matches_found }} + ✏️ Редактирай + 🗑️ Изтрий +
+
+ {% else %} +
+

Няма конфигурирани seed websites. Добавете първия

+
+ {% endif %} +
+
+ + +
+
+

🔑 Ключови Думи

+ ➕ Добави Нова +
+
+ {% if keywords %} +
+ + + + + + + + + + + + + + {% for keyword in keywords %} + + + + + + + + + + {% endfor %} + +
ИмеКлючова ДумаТипСтатусУвереностAuto ApproveДействия
{{ keyword.name }}{{ keyword.keyword|truncatechars:50 }}{{ keyword.get_keyword_type_display }} + {% if keyword.is_active %} + Активна + {% else %} + Неактивна + {% endif %} + {{ keyword.confidence_score }}% + {% if keyword.auto_approve %} + Да + {% else %} + Не + {% endif %} + + ✏️ Редактирай + 🗑️ Изтрий +
+
+ {% else %} +
+

Няма конфигурирани ключови думи. Добавете първата

+
+ {% endif %} +
+
+ + +
+
+

📊 Последна Активност

+
+
+
+
+

Последно Събрано Съдържание

+ {% if recent_crawled %} +
    + {% for content in recent_crawled %} +
  • + {{ content.title|truncatechars:50 }}
    + {{ content.crawled_at|date:"d.m.Y H:i" }} | {{ content.match_count }} съвпадения +
  • + {% endfor %} +
+ {% else %} +

Все още няма събрано съдържание.

+ {% endif %} +
+ +
+

Последни Автоматични Доклади

+ {% if recent_auto_reports %} +
    + {% for report in recent_auto_reports %} +
  • + {{ report.title|truncatechars:50 }}
    + + {{ report.created_at|date:"d.m.Y H:i" }} | + {{ report.get_status_display }} | + {{ report.confidence_score }}% + +
  • + {% endfor %} +
+ {% else %} +

Все още няма автоматично генерирани доклади.

+ {% endif %} +
+
+
+
+ + +{% if due_for_crawling %} +
+
+

⏰ Seed Websites за Crawling

+
+
+
+
+
+ Следните seed websites са готови за crawling: +
    + {% for seed in due_for_crawling %} +
  • {{ seed.name }} - {{ seed.url }}
  • + {% endfor %} +
+ Стартирай Crawling +
+
+
+
+{% endif %} + +{% endblock %} + diff --git a/templates/osint/approve_auto_report.html b/templates/osint/approve_auto_report.html new file mode 100644 index 0000000..24b0291 --- /dev/null +++ b/templates/osint/approve_auto_report.html @@ -0,0 +1,34 @@ +{% extends 'base.html' %} + +{% block title %}Одобряване на Автоматичен Доклад{% endblock %} + +{% block content %} +
+

Одобряване на Автоматичен Доклад

+ +
+

{{ object.title }}

+

Източник: {{ object.source_url }}

+

Увереност: {{ object.confidence_score }}%

+
+ {{ object.description|truncatewords:100 }} +
+
+ +
+ {% csrf_token %} +
+

+ Сигурни ли сте, че искате да одобрите този автоматично генериран доклад? + Това ще създаде публичен доклад, който ще бъде видим в платформата. +

+
+ +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/osint/auto_report_detail.html b/templates/osint/auto_report_detail.html new file mode 100644 index 0000000..f1679ca --- /dev/null +++ b/templates/osint/auto_report_detail.html @@ -0,0 +1,110 @@ +{% extends 'base.html' %} + +{% block title %}Детайли на Автоматичен Доклад - OSINT{% endblock %} + +{% block content %} +
+
+

Детайли на Автоматичен Доклад

+
+
+ +
+
+ + {{ auto_report.get_status_display }} + + + Увереност: {{ auto_report.confidence_score }}% + +
+ {% if auto_report.status == 'pending' %} + + {% endif %} +
+ + +
+

Информация за Доклада

+
+
Заглавие: {{ auto_report.title }}
+ +
Създадено: {{ auto_report.created_at|date:"d F Y, H:i" }}
+ {% if auto_report.reviewed_by %} +
Прегледано от: {{ auto_report.reviewed_by.username }}
+
Прегледано на: {{ auto_report.reviewed_at|date:"d F Y, H:i" }}
+ {% endif %} + {% if auto_report.published_at %} +
Публикувано на: {{ auto_report.published_at|date:"d F Y, H:i" }}
+ {% endif %} + {% if auto_report.report %} +
Свързан Доклад: Доклад #{{ auto_report.report.pk }}
+ {% endif %} +
+
+ + + {% if auto_report.matched_keywords.all %} +
+

Намерени Ключови Думи

+
+ {% for keyword in auto_report.matched_keywords.all %} + + {{ keyword.name }} ({{ keyword.confidence_score }}%) + + {% endfor %} +
+
+ {% endif %} + + +
+

Описание

+
+ {{ auto_report.description }} +
+
+ + + {% if auto_report.crawled_content %} +
+

Съдържание от Източника

+
+ Seed Website: {{ auto_report.crawled_content.seed_website.name }}
+ URL: {{ auto_report.crawled_content.url }}
+ Събрано на: {{ auto_report.crawled_content.crawled_at|date:"d F Y, H:i" }}
+ Брой Съвпадения: {{ auto_report.crawled_content.match_count }} +
+
+

Заглавие:

+

{{ auto_report.crawled_content.title }}

+

Съдържание:

+

{{ auto_report.crawled_content.content|truncatewords:500 }}

+
+
+ {% endif %} + + + {% if auto_report.review_notes %} +
+

Бележки от Прегледа

+
+ {{ auto_report.review_notes }} +
+
+ {% endif %} + + + +
+
+{% endblock %} + diff --git a/templates/osint/auto_report_list.html b/templates/osint/auto_report_list.html new file mode 100644 index 0000000..99ccb98 --- /dev/null +++ b/templates/osint/auto_report_list.html @@ -0,0 +1,109 @@ +{% extends 'base.html' %} + +{% block title %}Автоматично Генерирани Доклади - OSINT{% endblock %} + +{% block content %} +
+
+

Автоматично Генерирани Доклади от OSINT

+
+
+ +
+
+

{{ pending_count }}

+

Чакащи Преглед

+
+
+

{{ approved_count }}

+

Одобрени

+
+
+

{{ published_count }}

+

Публикувани

+
+
+

{{ rejected_count }}

+

Отхвърлени

+
+
+ + + + + {% if auto_reports %} +
+ {% for auto_report in auto_reports %} +
+
+

+ {{ auto_report.title }} +

+
+ {{ auto_report.get_status_display }} + + Увереност: {{ auto_report.confidence_score }}% + +
+
+
+ 🔗 {{ auto_report.source_url|truncatechars:60 }} + 📅 Създадено: {{ auto_report.created_at|date:"d F Y, H:i" }} + {% if auto_report.reviewed_by %} + 👤 Прегледано от: {{ auto_report.reviewed_by.username }} + {% endif %} +
+
+ {{ auto_report.description|truncatewords:50 }} +
+ {% if auto_report.matched_keywords.all %} +
+ Намерени Ключови Думи: + {% for keyword in auto_report.matched_keywords.all %} + {{ keyword.name }} + {% endfor %} +
+ {% endif %} +
+ Преглед + {% if auto_report.status == 'pending' %} + Одобри + Отхвърли + {% endif %} + {% if auto_report.report %} + Виж Доклад + {% endif %} +
+
+ {% endfor %} +
+ + + {% if page_obj.has_other_pages %} + + {% endif %} + {% else %} +
+

Няма автоматично генерирани доклади.

+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/osint/keyword_confirm_delete.html b/templates/osint/keyword_confirm_delete.html new file mode 100644 index 0000000..6539efe --- /dev/null +++ b/templates/osint/keyword_confirm_delete.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block title %}Изтриване на Ключова Дума{% endblock %} + +{% block content %} +
+

Изтриване на Ключова Дума

+ +
+
+
+ Внимание! Сигурни ли сте, че искате да изтриете "{{ object.name }}"? + Това действие не може да бъде отменено. +
+
+ +
+

{{ object.name }}

+

Ключова Дума: {{ object.keyword }}

+

Тип: {{ object.get_keyword_type_display }}

+

Увереност: {{ object.confidence_score }}%

+
+ +
+ {% csrf_token %} +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/osint/keyword_form.html b/templates/osint/keyword_form.html new file mode 100644 index 0000000..aa39321 --- /dev/null +++ b/templates/osint/keyword_form.html @@ -0,0 +1,93 @@ +{% extends 'base.html' %} + +{% block title %}{% if object %}Редактиране{% else %}Добавяне{% endif %} на Ключова Дума{% endblock %} + +{% block content %} +
+

{% if object %}Редактиране{% else %}Добавяне{% endif %} на Ключова Дума

+ +
+ {% csrf_token %} + +
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ +
+ + {{ form.keyword }} + {% if form.keyword.errors %} +
{{ form.keyword.errors }}
+ {% endif %} + Текст, фраза или regex патърн за търсене +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ +
+
+ + {{ form.keyword_type }} + {% if form.keyword_type.errors %} +
{{ form.keyword_type.errors }}
+ {% endif %} + exact, regex, phrase, domain, email, phone +
+ +
+ + {{ form.confidence_score }} + {% if form.confidence_score.errors %} +
{{ form.confidence_score.errors }}
+ {% endif %} +
+
+ +
+
+ + {% if form.is_active.errors %} +
{{ form.is_active.errors }}
+ {% endif %} +
+ +
+ + {% if form.case_sensitive.errors %} +
{{ form.case_sensitive.errors }}
+ {% endif %} +
+ +
+ + {% if form.auto_approve.errors %} +
{{ form.auto_approve.errors }}
+ {% endif %} + Автоматично одобряване при увереност ≥ 80% +
+
+ +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/osint/reject_auto_report.html b/templates/osint/reject_auto_report.html new file mode 100644 index 0000000..222aa4d --- /dev/null +++ b/templates/osint/reject_auto_report.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block title %}Отхвърляне на Автоматичен Доклад{% endblock %} + +{% block content %} +
+

Отхвърляне на Автоматичен Доклад

+ +
+

{{ object.title }}

+

Източник: {{ object.source_url }}

+

Увереност: {{ object.confidence_score }}%

+
+ {{ object.description|truncatewords:100 }} +
+
+ +
+ {% csrf_token %} +
+ + +
+ +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/osint/result_list.html b/templates/osint/result_list.html new file mode 100644 index 0000000..9484c9c --- /dev/null +++ b/templates/osint/result_list.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} + +{% block title %}OSINT Резултати - Официален Портал{% endblock %} + +{% block content %} +
+
+

OSINT Резултати за Доклад: {{ report.title }}

+
+
+ {% if results %} +
+ {% for result in results %} +
+
+

{{ result.get_data_type_display }}

+
+ {{ result.source }} + Увереност: {{ result.confidence_level }}% + {% if result.is_verified %} + Потвърдено + {% endif %} +
+
+
+ Събрано: {{ result.collected_at|date:"d F Y, H:i" }} +
+ {% if result.processed_data %} +
+

Обработени Данни

+
{{ result.processed_data|safe }}
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

Все още няма налични OSINT резултати за този доклад.

+
+ {% endif %} + + +
+
+{% endblock %} + diff --git a/templates/osint/seed_website_confirm_delete.html b/templates/osint/seed_website_confirm_delete.html new file mode 100644 index 0000000..12295d2 --- /dev/null +++ b/templates/osint/seed_website_confirm_delete.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block title %}Изтриване на Seed Website{% endblock %} + +{% block content %} +
+

Изтриване на Seed Website

+ +
+
+
+ Внимание! Сигурни ли сте, че искате да изтриете "{{ object.name }}"? + Това действие не може да бъде отменено. +
+
+ +
+

{{ object.name }}

+

URL: {{ object.url }}

+

Страници събрани: {{ object.pages_crawled }}

+

Съвпадения: {{ object.matches_found }}

+
+ +
+ {% csrf_token %} +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/osint/seed_website_form.html b/templates/osint/seed_website_form.html new file mode 100644 index 0000000..1f36047 --- /dev/null +++ b/templates/osint/seed_website_form.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} + +{% block title %}{% if object %}Редактиране{% else %}Добавяне{% endif %} на Seed Website{% endblock %} + +{% block content %} +
+

{% if object %}Редактиране{% else %}Добавяне{% endif %} на Seed Website

+ +
+ {% csrf_token %} + +
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ +
+ + {{ form.url }} + {% if form.url.errors %} +
{{ form.url.errors }}
+ {% endif %} + Основният URL адрес за crawling +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ +
+
+ + {% if form.is_active.errors %} +
{{ form.is_active.errors }}
+ {% endif %} +
+ +
+ + {{ form.priority }} + {% if form.priority.errors %} +
{{ form.priority.errors }}
+ {% endif %} +
+
+ +
+
+ + {{ form.crawl_depth }} + {% if form.crawl_depth.errors %} +
{{ form.crawl_depth.errors }}
+ {% endif %} + 0 = само тази страница, 1 = + директни линкове, 2 = + 2 нива +
+ +
+ + {{ form.crawl_interval_hours }} + {% if form.crawl_interval_hours.errors %} +
{{ form.crawl_interval_hours.errors }}
+ {% endif %} + Колко често да се crawl-ва +
+
+ +
+ + {{ form.allowed_domains_text }} + {% if form.allowed_domains_text.errors %} +
{{ form.allowed_domains_text.errors }}
+ {% endif %} + {{ form.allowed_domains_text.help_text }} +
+ +
+ + {{ form.user_agent }} + {% if form.user_agent.errors %} +
{{ form.user_agent.errors }}
+ {% endif %} +
+ +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/osint/start_crawling.html b/templates/osint/start_crawling.html new file mode 100644 index 0000000..4ae28fe --- /dev/null +++ b/templates/osint/start_crawling.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} + +{% block title %}Стартиране на OSINT Crawling{% endblock %} + +{% block content %} +
+

🚀 Стартиране на OSINT Crawling

+ +
+ {% csrf_token %} + +
+ + + Изберете конкретен seed website или оставете празно за всички +
+ +
+
+ + + Максимален брой страници за crawl на seed website +
+ +
+ + + Забавяне между заявките +
+
+ +
+
+
+ Информация: Crawling-ът ще стартира в background. Резултатите ще се появят след няколко минути. + Проверете "Crawled Content" и "Auto-Generated Reports" в админ таблото. +
+
+ +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/osint/task_detail.html b/templates/osint/task_detail.html new file mode 100644 index 0000000..067874c --- /dev/null +++ b/templates/osint/task_detail.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} + +{% block title %}Детайли на OSINT Задача - Официален Портал{% endblock %} + +{% block content %} +
+
+

Детайли на OSINT Задача

+
+
+
+

Информация за Задачата

+
+
Тип: {{ task.get_task_type_display }}
+
Статус: {{ task.get_status_display }}
+ +
Създадено: {{ task.created_at|date:"d F Y, H:i" }}
+ {% if task.started_at %} +
Започнато: {{ task.started_at|date:"d F Y, H:i" }}
+ {% endif %} + {% if task.completed_at %} +
Завършено: {{ task.completed_at|date:"d F Y, H:i" }}
+ {% endif %} +
Брой Опити: {{ task.retry_count }}
+
+
+ + {% if task.parameters %} +
+

Параметри

+
{{ task.parameters|safe }}
+
+ {% endif %} + + {% if task.result %} +
+

Резултат

+
{{ task.result|safe }}
+
+ {% endif %} + + {% if task.error_message %} +
+

Грешка

+
+
+
{{ task.error_message }}
+
+
+ {% endif %} + + +
+
+{% endblock %} + diff --git a/templates/osint/task_list.html b/templates/osint/task_list.html new file mode 100644 index 0000000..8597072 --- /dev/null +++ b/templates/osint/task_list.html @@ -0,0 +1,62 @@ +{% extends 'base.html' %} + +{% block title %}OSINT Задачи - Официален Портал{% endblock %} + +{% block content %} +
+
+

OSINT Задачи

+
+
+ + + {% if tasks %} +
+ {% for task in tasks %} +
+
+

{{ task.get_task_type_display }}

+ {{ task.get_status_display }} +
+
+ Доклад: {{ task.report.title }} + Създадено: {{ task.created_at|date:"d F Y, H:i" }} + {% if task.completed_at %} + Завършено: {{ task.completed_at|date:"d F Y, H:i" }} + {% endif %} +
+ {% if task.error_message %} +
+ Грешка: {{ task.error_message }} +
+ {% endif %} +
+ {% endfor %} +
+ + + {% else %} +
+

Не са намерени OSINT задачи.

+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/reports/contact.html b/templates/reports/contact.html new file mode 100644 index 0000000..777db01 --- /dev/null +++ b/templates/reports/contact.html @@ -0,0 +1,355 @@ +{% extends 'base.html' %} + +{% block title %}Свържете се с нас - Контакти{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+

Свържете се с нас

+

Имате въпрос, предложение или нужда от помощ? Нашият екип е готов да ви помогне.

+
+ + + +
+
+
+

Изпратете ни съобщение

+
+
+
+ {% csrf_token %} + + {# Hidden bot protection fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + +
+
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ +
+ + {{ form.email }} + {% if form.email.errors %} +
{{ form.email.errors }}
+ {% endif %} +
+
+ +
+ + {{ form.inquiry_type }} + {% if form.inquiry_type.errors %} +
{{ form.inquiry_type.errors }}
+ {% endif %} +
+ +
+ + {{ form.subject }} + {% if form.subject.errors %} +
{{ form.subject.errors }}
+ {% endif %} +
+ +
+ + {{ form.message }} + {% if form.message.errors %} +
{{ form.message.errors }}
+ {% endif %} + Моля, опишете подробно вашия въпрос или проблем. +
+ +
+ + Отказ +
+
+
+
+
+ + +
+
+

Често задавани въпроси

+

Най-често задавани въпроси и отговори

+
+
+
+

Как да докладвам измама?

+

Можете да докладвате измама като използвате формата за докладване. Моля, предоставете всички налични детайли за да помогнем на другите граждани.

+
+ +
+

Колко време отнема прегледът на доклад?

+

Нашият екип преглежда всички доклади в рамките на 2-5 работни дни. Ще получите уведомление по имейл когато докладът ви бъде прегледан.

+
+ +
+

Мога ли да остана анонимен?

+

Да, можете да докладвате анонимно. Вашата лична информация ще бъде защитена и няма да бъде разкрита публично.

+
+ +
+

Как да проверя статуса на моя доклад?

+

Ако сте регистриран потребител, можете да видите статуса на всички ваши доклади в секцията Моите Доклади.

+
+
+
+{% endblock %} + diff --git a/templates/reports/create.html b/templates/reports/create.html new file mode 100644 index 0000000..2e6c091 --- /dev/null +++ b/templates/reports/create.html @@ -0,0 +1,101 @@ +{% extends 'base.html' %} + +{% block title %}Докладване на Измама - Официален Портал{% endblock %} + +{% block content %} +
+

Докладване на Измама или Мошеничество

+

Помогнете да защитим другите граждани, като докладвате измами. Всички доклади се преглеждат от модератори преди публикуване.

+ +
+ {% csrf_token %} +
+ + {{ form.title }} + {% if form.title.errors %} +
{{ form.title.errors }}
+ {% endif %} + Кратко и описателно заглавие +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} + Предоставете подробна информация за измамата +
+ +
+ + {{ form.scam_type }} + {% if form.scam_type.errors %} +
{{ form.scam_type.errors }}
+ {% endif %} +
+ +
+
+ + {{ form.reported_url }} + {% if form.reported_url.errors %} +
{{ form.reported_url.errors }}
+ {% endif %} +
+ +
+ + {{ form.reported_email }} + {% if form.reported_email.errors %} +
{{ form.reported_email.errors }}
+ {% endif %} +
+
+ +
+
+ + {{ form.reported_phone }} + {% if form.reported_phone.errors %} +
{{ form.reported_phone.errors }}
+ {% endif %} +
+ +
+ + {{ form.reported_company }} + {% if form.reported_company.errors %} +
{{ form.reported_company.errors }}
+ {% endif %} +
+
+ +
+ + {{ form.tags }} + {% if form.tags.errors %} +
{{ form.tags.errors }}
+ {% endif %} + Задръжте Ctrl/Cmd за избор на множество етикети +
+ +
+ + {% if form.is_anonymous.errors %} +
{{ form.is_anonymous.errors }}
+ {% endif %} +
+ +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/reports/delete.html b/templates/reports/delete.html new file mode 100644 index 0000000..f607a4d --- /dev/null +++ b/templates/reports/delete.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} + +{% block title %}Изтриване на Доклад - Официален Портал{% endblock %} + +{% block content %} +
+

Изтриване на Доклад

+
+
+
+

Сигурни ли сте, че искате да изтриете този доклад?

+

{{ object.title }}

+

Това действие не може да бъде отменено.

+
+
+ +
+ {% csrf_token %} +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/reports/detail.html b/templates/reports/detail.html new file mode 100644 index 0000000..d08cf47 --- /dev/null +++ b/templates/reports/detail.html @@ -0,0 +1,193 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ report.title }} - Портал за Докладване на Измами{% endblock %} + +{% block schema_type %}Article{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+

{{ report.title }}

+
+ {{ report.get_scam_type_display }} + {% if report.status == 'verified' %} + Потвърдено + {% elif report.status == 'rejected' %} + Отхвърлено + {% elif report.status == 'pending' %} + Чака Преглед + {% elif report.status == 'under_review' %} + В Преглед + {% endif %} + {% if report.verification_score %} + Проверка: {{ report.verification_score }}% + {% endif %} +
+
+ +
+ {% if report.status == 'rejected' and user.is_authenticated and report.reporter == user and rejection_action and rejection_action.reason %} +
+
+
+ Докладът е отхвърлен +

Причина за отхвърляне:

+

+ {{ rejection_action.reason }} +

+

+ Отхвърлен на: {{ rejection_action.created_at|date:"d.m.Y в H:i" }}
+ От: {{ rejection_action.moderator.username|default:"модератор" }} +

+ +
+
+ {% endif %} + +
+
+ Статус: {{ report.get_status_display }} +
+
+ Докладвано на: {{ report.created_at|date:"d F Y, H:i" }} +
+
+ Докладвано от: {{ report.get_reporter_display }} +
+ {% if report.verified_at %} +
+ Потвърдено на: {{ report.verified_at|date:"d F Y, H:i" }} +
+ {% endif %} +
+ +
+

Описание

+

{{ report.description|linebreaks }}

+
+ +
+

Докладвани Обекти

+
    + {% if report.reported_url %} +
  • + URL: {{ report.reported_url }} +
  • + {% endif %} + {% if report.reported_email %} +
  • + Имейл: {{ report.reported_email }} +
  • + {% endif %} + {% if report.reported_phone %} +
  • + Телефон: {{ report.reported_phone }} +
  • + {% endif %} + {% if report.reported_company %} +
  • + Фирма: {{ report.reported_company }} +
  • + {% endif %} +
+
+ + {% if report.tags.all %} +
+

Етикети

+
+ {% for tag in report.tags.all %} + {{ tag.name }} + {% endfor %} +
+
+ {% endif %} + + {% if report.verifications.all %} +
+

Детайли за Проверка

+ {% for verification in report.verifications.all %} +
+ {{ verification.get_verification_method_display }}: + Увереност: {{ verification.confidence_score }}% + {% if verification.notes %} +

{{ verification.notes }}

+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if user.is_authenticated and report.reporter == user %} +
+ {% if report.status == 'pending' %} + Редактиране + Изтриване + {% elif report.status == 'rejected' %} + Редактиране и Повторно Изпращане + Изтриване + {% endif %} +
+ {% endif %} + + {% if report.status == 'verified' and report.is_public %} +
+
+

+ ⚠️ + Смятате ли, че този доклад е несправедлив? +

+

+ Ако сте обект на този доклад и смятате, че информацията е невярна или несправедлива, можете да подадете заявка за премахване. +

+ + 📝 Заявка за Премахване на Доклад + +
+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/reports/edit.html b/templates/reports/edit.html new file mode 100644 index 0000000..204cfbf --- /dev/null +++ b/templates/reports/edit.html @@ -0,0 +1,73 @@ +{% extends 'base.html' %} + +{% block title %}Редактиране на Доклад - Официален Портал{% endblock %} + +{% block content %} +
+

Редактиране на Доклад

+ {% if object.status == 'rejected' %} +
+
+
+ Този доклад е бил отхвърлен. +

След като направите промените, докладът ще бъде изпратен отново за преглед.

+
+
+ {% else %} +

Можете да редактирате доклади, които чакат преглед.

+ {% endif %} + +
+ {% csrf_token %} +
+ + {{ form.title }} +
+ +
+ + {{ form.description }} +
+ +
+ + {{ form.scam_type }} +
+ +
+
+ + {{ form.reported_url }} +
+ +
+ + {{ form.reported_email }} +
+
+ +
+
+ + {{ form.reported_phone }} +
+ +
+ + {{ form.reported_company }} +
+
+ +
+ + {{ form.tags }} +
+ +
+ + Отказ +
+
+
+{% endblock %} + diff --git a/templates/reports/home.html b/templates/reports/home.html new file mode 100644 index 0000000..e987044 --- /dev/null +++ b/templates/reports/home.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Начало - Портал за Докладване на Измами{% endblock %} + +{% block schema_type %}WebSite{% endblock %} + +{% block extra_head %} + + + + + +{% endblock %} + +{% block content %} +
+
+
+

Добре дошли в Портала за Докладване на Измами

+

Докладвайте измами, за да помогнете на другите граждани в българското интернет пространство.

+ +
+
+ Официален Портал за Докладване на Измами +
+
+
+ +
+
+

Статистика на Платформата

+
+
+
+

{{ total_reports|default:0 }}

+

Потвърдени Доклади

+
+
+
+ +{% if recent_reports %} +
+
+

Последни Доклади

+
+
+ {% for report in recent_reports %} +
+
+

{{ report.title }}

+ {{ report.get_scam_type_display }} +
+
+ {{ report.created_at|date:"d M Y" }} + от {{ report.get_reporter_display }} +
+
+ {% endfor %} +
+
+{% endif %} + +{% if scam_types %} +
+
+

Разпределение по Видове Измами

+
+
+
+ {% for type in scam_types %} +
+
+ + {{ type.display_name }} + +
+ {{ type.count }} + доклад{{ type.count|pluralize:"а,а" }} +
+
+
+
+
+
+ {{ type.percentage }}% от общия брой +
+
+ {% endfor %} +
+
+
+{% endif %} +{% endblock %} + diff --git a/templates/reports/list.html b/templates/reports/list.html new file mode 100644 index 0000000..a5ff0db --- /dev/null +++ b/templates/reports/list.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} + +{% block title %}Потвърдени Доклади - Официален Портал{% endblock %} + +{% block content %} +
+
+

Потвърдени Доклади за Измами

+ +
+
+ + {% if reports %} +
+ {% for report in reports %} +
+
+

{{ report.title }}

+
+ {{ report.get_scam_type_display }} + {% if report.is_auto_discovered %} + 🤖 OSINT + {% endif %} +
+
+
+ {{ report.created_at|date:"d F Y" }} + от {{ report.get_reporter_display }} + {% if report.verification_score %} + Проверка: {{ report.verification_score }}% + {% endif %} +
+

{{ report.description|truncatewords:50 }}

+ {% if report.tags.all %} +
+ {% for tag in report.tags.all %} + {{ tag.name }} + {% endfor %} +
+ {% endif %} +
+ {% if report.reported_url %} + URL: {{ report.reported_url|truncatechars:50 }} + {% endif %} + {% if report.reported_email %} + Имейл: {{ report.reported_email }} + {% endif %} + {% if report.reported_phone %} + Телефон: {{ report.reported_phone }} + {% endif %} +
+
+ {% endfor %} +
+ + + {% else %} +
+

Все още няма потвърдени доклади. Бъдете първият, който докладва измама!

+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/reports/my_reports.html b/templates/reports/my_reports.html new file mode 100644 index 0000000..31f92ac --- /dev/null +++ b/templates/reports/my_reports.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} + +{% block title %}Моите Доклади - Официален Портал{% endblock %} + +{% block content %} +
+
+

Моите Доклади

+ Докладване на Измама +
+
+ + {% if reports %} +
+ {% for report in reports %} +
+
+

{{ report.title }}

+ {{ report.get_status_display }} +
+
+ {{ report.created_at|date:"d F Y" }} + {{ report.get_scam_type_display }} +
+ {% if report.status == 'rejected' and report.rejection_action and report.rejection_reason %} +
+
+
+ Докладът е отхвърлен +

Причина: {{ report.rejection_reason }}

+

+ Отхвърлен на {{ report.rejection_action.created_at|date:"d.m.Y в H:i" }} от {{ report.rejection_action.moderator.username|default:"модератор" }} +

+
+
+ {% endif %} +

{{ report.description|truncatewords:30 }}

+
+ Преглед + {% if report.status == 'pending' %} + Редактиране + Изтриване + {% elif report.status == 'rejected' %} + Редактиране и Повторно Изпращане + Изтриване + {% endif %} +
+
+ {% endfor %} +
+ + + {% else %} +
+

Все още не сте изпратили доклади. Докладвайте първата си измама!

+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/reports/search.html b/templates/reports/search.html new file mode 100644 index 0000000..3faac64 --- /dev/null +++ b/templates/reports/search.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} + +{% block title %}Търсене на Доклади - Официален Портал{% endblock %} + +{% block content %} +
+
+

Търсене на Доклади за Измами

+
+
+
+
+
+ +
+
+ +
+ +
+
+ + {% if reports %} +
+

Намерени {{ reports.count }} резултат(а)

+
+ {% for report in reports %} +
+
+

{{ report.title }}

+ {{ report.get_scam_type_display }} +
+
+ {{ report.created_at|date:"d F Y" }} +
+

{{ report.description|truncatewords:30 }}

+
+ {% endfor %} +
+
+ {% elif request.GET.q %} +
+

Не са намерени резултати за "{{ request.GET.q }}". Опитайте с различни термини за търсене.

+
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/reports/takedown_request.html b/templates/reports/takedown_request.html new file mode 100644 index 0000000..6ca01d7 --- /dev/null +++ b/templates/reports/takedown_request.html @@ -0,0 +1,244 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Заявка за Премахване - {{ report.title }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+

Заявка за Премахване на Доклад

+

Ако сте обект на този доклад и смятате, че информацията е невярна или несправедлива, моля попълнете формата по-долу.

+
+ + +
+

📄 Доклад, за който се отнася заявката:

+

Заглавие: {{ report.title }}

+

Вид измама: {{ report.get_scam_type_display }}

+

Докладван на: {{ report.created_at|date:"d F Y" }}

+ {% if report.reported_url %} +

URL: {{ report.reported_url }}

+ {% endif %} +
+ + +
+

Важна информация

+
    +
  • Всички заявки се преглеждат внимателно от нашия екип
  • +
  • Ще получите отговор по имейл в рамките на 2-5 работни дни
  • +
  • Моля, предоставете всички налични доказателства, които подкрепят вашата заявка
  • +
  • Лъжливи или злонамерени заявки могат да доведат до правни последици
  • +
+
+ + +
+
+

Форма за Заявка

+
+
+
+ {% csrf_token %} + + {# Hidden bot protection fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + +
+
+ + {{ form.requester_name }} + {% if form.requester_name.errors %} +
{{ form.requester_name.errors }}
+ {% endif %} +
+ +
+ + {{ form.requester_email }} + {% if form.requester_email.errors %} +
{{ form.requester_email.errors }}
+ {% endif %} +
+
+ +
+ + {{ form.requester_phone }} + {% if form.requester_phone.errors %} +
{{ form.requester_phone.errors }}
+ {% endif %} + Незадължително поле +
+ +
+ + {{ form.reason }} + {% if form.reason.errors %} +
{{ form.reason.errors }}
+ {% endif %} + {% if form.reason.help_text %} + {{ form.reason.help_text }} + {% endif %} +
+ +
+ + {{ form.evidence }} + {% if form.evidence.errors %} +
{{ form.evidence.errors }}
+ {% endif %} + {% if form.evidence.help_text %} + {{ form.evidence.help_text }} + {% endif %} +
+ +
+ + Отказ +
+
+
+
+{% endblock %} +