update
This commit is contained in:
67
.gitignore
vendored
Normal file
67
.gitignore
vendored
Normal file
@@ -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
|
||||
|
||||
727
FRAUD_SCAM_PLATFORM_ROADMAP.md
Normal file
727
FRAUD_SCAM_PLATFORM_ROADMAP.md
Normal file
@@ -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*
|
||||
|
||||
266
OSINT_SYSTEM_README.md
Normal file
266
OSINT_SYSTEM_README.md
Normal file
@@ -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/<id>/` - View report details
|
||||
- `/osint/auto-reports/<id>/approve/` - Approve report
|
||||
- `/osint/auto-reports/<id>/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
|
||||
|
||||
185
README.md
Normal file
185
README.md
Normal file
@@ -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].
|
||||
|
||||
142
SAMPLE_DATA.md
Normal file
142
SAMPLE_DATA.md
Normal file
@@ -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
|
||||
|
||||
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
47
accounts/admin.py
Normal file
47
accounts/admin.py
Normal file
@@ -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'
|
||||
6
accounts/apps.py
Normal file
6
accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
141
accounts/form_mixins.py
Normal file
141
accounts/form_mixins.py
Normal file
@@ -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
|
||||
|
||||
138
accounts/forms.py
Normal file
138
accounts/forms.py
Normal file
@@ -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'
|
||||
)
|
||||
|
||||
0
accounts/management/__init__.py
Normal file
0
accounts/management/__init__.py
Normal file
0
accounts/management/commands/__init__.py
Normal file
0
accounts/management/commands/__init__.py
Normal file
110
accounts/management/commands/check_security.py
Normal file
110
accounts/management/commands/check_security.py
Normal file
@@ -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))
|
||||
|
||||
45
accounts/management/commands/create_initial_tags.py
Normal file
45
accounts/management/commands/create_initial_tags.py
Normal file
@@ -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.')
|
||||
)
|
||||
|
||||
377
accounts/management/commands/create_sample_data.py
Normal file
377
accounts/management/commands/create_sample_data.py
Normal file
@@ -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'))
|
||||
|
||||
120
accounts/management/commands/create_test_users.py
Normal file
120
accounts/management/commands/create_test_users.py
Normal file
@@ -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))
|
||||
|
||||
279
accounts/middleware.py
Normal file
279
accounts/middleware.py
Normal file
@@ -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
|
||||
|
||||
114
accounts/migrations/0001_initial.py
Normal file
114
accounts/migrations/0001_initial.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
accounts/migrations/0002_alter_activitylog_action.py
Normal file
18
accounts/migrations/0002_alter_activitylog_action.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
175
accounts/models.py
Normal file
175
accounts/models.py
Normal file
@@ -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}"
|
||||
134
accounts/security.py
Normal file
134
accounts/security.py
Normal file
@@ -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()
|
||||
|
||||
3
accounts/tests.py
Normal file
3
accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
33
accounts/urls.py
Normal file
33
accounts/urls.py
Normal file
@@ -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/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||
path('password/reset/complete/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
|
||||
]
|
||||
|
||||
433
accounts/views.py
Normal file
433
accounts/views.py
Normal file
@@ -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)
|
||||
0
analytics/__init__.py
Normal file
0
analytics/__init__.py
Normal file
32
analytics/admin.py
Normal file
32
analytics/admin.py
Normal file
@@ -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')
|
||||
6
analytics/apps.py
Normal file
6
analytics/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'analytics'
|
||||
82
analytics/migrations/0001_initial.py
Normal file
82
analytics/migrations/0001_initial.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
analytics/migrations/__init__.py
Normal file
0
analytics/migrations/__init__.py
Normal file
98
analytics/models.py
Normal file
98
analytics/models.py
Normal file
@@ -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}"
|
||||
3
analytics/tests.py
Normal file
3
analytics/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
analytics/urls.py
Normal file
14
analytics/urls.py
Normal file
@@ -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'),
|
||||
]
|
||||
|
||||
174
analytics/views.py
Normal file
174
analytics/views.py
Normal file
@@ -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
|
||||
0
fraud_platform/__init__.py
Normal file
0
fraud_platform/__init__.py
Normal file
16
fraud_platform/asgi.py
Normal file
16
fraud_platform/asgi.py
Normal file
@@ -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()
|
||||
71
fraud_platform/context_processors.py
Normal file
71
fraud_platform/context_processors.py
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
9
fraud_platform/settings/__init__.py
Normal file
9
fraud_platform/settings/__init__.py
Normal file
@@ -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 *
|
||||
|
||||
241
fraud_platform/settings/base.py
Normal file
241
fraud_platform/settings/base.py
Normal file
@@ -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
|
||||
}
|
||||
|
||||
33
fraud_platform/settings/development.py
Normal file
33
fraud_platform/settings/development.py
Normal file
@@ -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
|
||||
|
||||
54
fraud_platform/settings/production.py
Normal file
54
fraud_platform/settings/production.py
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
54
fraud_platform/sitemaps.py
Normal file
54
fraud_platform/sitemaps.py
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
57
fraud_platform/urls.py
Normal file
57
fraud_platform/urls.py
Normal file
@@ -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)
|
||||
16
fraud_platform/wsgi.py
Normal file
16
fraud_platform/wsgi.py
Normal file
@@ -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()
|
||||
0
legal/__init__.py
Normal file
0
legal/__init__.py
Normal file
47
legal/admin.py
Normal file
47
legal/admin.py
Normal file
@@ -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'
|
||||
6
legal/apps.py
Normal file
6
legal/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LegalConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'legal'
|
||||
22
legal/forms.py
Normal file
22
legal/forms.py
Normal file
@@ -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...'
|
||||
}),
|
||||
}
|
||||
|
||||
83
legal/migrations/0001_initial.py
Normal file
83
legal/migrations/0001_initial.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
legal/migrations/__init__.py
Normal file
0
legal/migrations/__init__.py
Normal file
210
legal/models.py
Normal file
210
legal/models.py
Normal file
@@ -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}"
|
||||
3
legal/tests.py
Normal file
3
legal/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
legal/urls.py
Normal file
16
legal/urls.py
Normal file
@@ -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/<int:pk>/', views.DataRequestDetailView.as_view(), name='data_request_detail'),
|
||||
path('cookie-consent/', views.cookie_consent_view, name='cookie_consent'),
|
||||
]
|
||||
|
||||
111
legal/views.py
Normal file
111
legal/views.py
Normal file
@@ -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)
|
||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -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()
|
||||
0
moderation/__init__.py
Normal file
0
moderation/__init__.py
Normal file
32
moderation/admin.py
Normal file
32
moderation/admin.py
Normal file
@@ -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')
|
||||
6
moderation/apps.py
Normal file
6
moderation/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModerationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'moderation'
|
||||
77
moderation/migrations/0001_initial.py
Normal file
77
moderation/migrations/0001_initial.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
moderation/migrations/0002_alter_moderationaction_reason.py
Normal file
18
moderation/migrations/0002_alter_moderationaction_reason.py
Normal file
@@ -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)'),
|
||||
),
|
||||
]
|
||||
0
moderation/migrations/__init__.py
Normal file
0
moderation/migrations/__init__.py
Normal file
145
moderation/models.py
Normal file
145
moderation/models.py
Normal file
@@ -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'})"
|
||||
3
moderation/tests.py
Normal file
3
moderation/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
moderation/urls.py
Normal file
16
moderation/urls.py
Normal file
@@ -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/<int:pk>/', views.ReportModerationView.as_view(), name='report_detail'),
|
||||
path('report/<int:pk>/approve/', views.ApproveReportView.as_view(), name='approve'),
|
||||
path('report/<int:pk>/reject/', views.RejectReportView.as_view(), name='reject'),
|
||||
]
|
||||
|
||||
129
moderation/views.py
Normal file
129
moderation/views.py
Normal file
@@ -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')
|
||||
0
osint/__init__.py
Normal file
0
osint/__init__.py
Normal file
246
osint/admin.py
Normal file
246
osint/admin.py
Normal file
@@ -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('<span style="color: red;">●</span> Inactive')
|
||||
if not obj.last_crawled_at:
|
||||
return format_html('<span style="color: orange;">●</span> Never Crawled')
|
||||
|
||||
hours_since = (timezone.now() - obj.last_crawled_at).total_seconds() / 3600
|
||||
if hours_since > obj.crawl_interval_hours * 2:
|
||||
return format_html('<span style="color: orange;">●</span> Overdue')
|
||||
elif hours_since > obj.crawl_interval_hours:
|
||||
return format_html('<span style="color: yellow;">●</span> Due Soon')
|
||||
else:
|
||||
return format_html('<span style="color: green;">●</span> 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('<a href="{}">View Report #{}</a>', 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.')
|
||||
6
osint/apps.py
Normal file
6
osint/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OsintConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'osint'
|
||||
97
osint/forms.py
Normal file
97
osint/forms.py
Normal file
@@ -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'}),
|
||||
}
|
||||
|
||||
2
osint/management/__init__.py
Normal file
2
osint/management/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Management package
|
||||
|
||||
2
osint/management/commands/__init__.py
Normal file
2
osint/management/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Management commands package
|
||||
|
||||
360
osint/management/commands/crawl_osint.py
Normal file
360
osint/management/commands/crawl_osint.py
Normal file
@@ -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}'
|
||||
))
|
||||
|
||||
80
osint/migrations/0001_initial.py
Normal file
80
osint/migrations/0001_initial.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
0
osint/migrations/__init__.py
Normal file
0
osint/migrations/__init__.py
Normal file
468
osint/models.py
Normal file
468
osint/models.py
Normal file
@@ -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()}"
|
||||
75
osint/tasks.py
Normal file
75
osint/tasks.py
Normal file
@@ -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"
|
||||
|
||||
3
osint/tests.py
Normal file
3
osint/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
35
osint/urls.py
Normal file
35
osint/urls.py
Normal file
@@ -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/<int:pk>/edit/', views.SeedWebsiteUpdateView.as_view(), name='seed_edit'),
|
||||
path('admin-dashboard/seeds/<int:pk>/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/<int:pk>/edit/', views.OSINTKeywordUpdateView.as_view(), name='keyword_edit'),
|
||||
path('admin-dashboard/keywords/<int:pk>/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/<int:pk>/', views.OSINTTaskDetailView.as_view(), name='task_detail'),
|
||||
path('results/<int:report_id>/', views.OSINTResultListView.as_view(), name='result_list'),
|
||||
path('auto-reports/', views.AutoReportListView.as_view(), name='auto_report_list'),
|
||||
path('auto-reports/<int:pk>/', views.AutoReportDetailView.as_view(), name='auto_report_detail'),
|
||||
path('auto-reports/<int:pk>/approve/', views.ApproveAutoReportView.as_view(), name='approve_auto_report'),
|
||||
path('auto-reports/<int:pk>/reject/', views.RejectAutoReportView.as_view(), name='reject_auto_report'),
|
||||
]
|
||||
|
||||
346
osint/views.py
Normal file
346
osint/views.py
Normal file
@@ -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
|
||||
0
reports/__init__.py
Normal file
0
reports/__init__.py
Normal file
247
reports/admin.py
Normal file
247
reports/admin.py
Normal file
@@ -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)
|
||||
15
reports/apps.py
Normal file
15
reports/apps.py
Normal file
@@ -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
|
||||
123
reports/email_backend.py
Normal file
123
reports/email_backend.py
Normal file
@@ -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
|
||||
|
||||
124
reports/forms.py
Normal file
124
reports/forms.py
Normal file
@@ -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': 'Ако имате документи, снимки или друга информация, която подкрепя вашата заявка, моля опишете я тук.',
|
||||
}
|
||||
103
reports/migrations/0001_initial.py
Normal file
103
reports/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
18
reports/migrations/0002_scamreport_is_auto_discovered.py
Normal file
18
reports/migrations/0002_scamreport_is_auto_discovered.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
29
reports/migrations/0003_sitesettings.py
Normal file
29
reports/migrations/0003_sitesettings.py
Normal file
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
43
reports/migrations/0005_takedownrequest.py
Normal file
43
reports/migrations/0005_takedownrequest.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
0
reports/migrations/__init__.py
Normal file
0
reports/migrations/__init__.py
Normal file
411
reports/models.py
Normal file
411
reports/models.py
Normal file
@@ -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}"
|
||||
3
reports/tests.py
Normal file
3
reports/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
33
reports/urls.py
Normal file
33
reports/urls.py
Normal file
@@ -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/<int:pk>/', 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/<int:pk>/edit/', views.ReportEditView.as_view(), name='edit'),
|
||||
path('reports/<int:pk>/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/<int:report_pk>/takedown/', views.TakedownRequestView.as_view(), name='takedown_request'),
|
||||
]
|
||||
|
||||
454
reports/views.py
Normal file
454
reports/views.py
Normal file
@@ -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})
|
||||
43
requirements.txt
Normal file
43
requirements.txt
Normal file
@@ -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
|
||||
|
||||
292
templates/404.html
Normal file
292
templates/404.html
Normal file
@@ -0,0 +1,292 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Страницата не е намерена - 404{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.error-404-container {
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.error-404-content {
|
||||
text-align: center;
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.error-404-number {
|
||||
font-family: 'Roboto Slab', serif;
|
||||
font-size: 12rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--gov-primary) 0%, var(--gov-secondary) 50%, var(--gov-accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 4px 20px rgba(0, 51, 102, 0.2);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.error-404-number::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, rgba(0, 102, 204, 0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
animation: ripple 3s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-404-title {
|
||||
font-family: 'Roboto Slab', serif;
|
||||
font-size: 2.5rem;
|
||||
color: var(--gov-primary);
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error-404-message {
|
||||
font-size: 1.2rem;
|
||||
color: var(--gov-gray);
|
||||
margin-bottom: 2.5rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.error-404-illustration {
|
||||
margin: 2rem 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error-404-illustration svg {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.error-404-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.error-404-actions .btn {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.error-404-helpful-links {
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid var(--gov-gray-light);
|
||||
}
|
||||
|
||||
.error-404-helpful-links h3 {
|
||||
font-family: 'Roboto Slab', serif;
|
||||
color: var(--gov-primary);
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.helpful-links-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.helpful-link {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, var(--gov-light) 0%, var(--gov-white) 100%);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--gov-primary);
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 2px 8px var(--gov-shadow);
|
||||
}
|
||||
|
||||
.helpful-link:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 16px var(--gov-shadow-lg);
|
||||
border-color: var(--gov-secondary);
|
||||
color: var(--gov-secondary);
|
||||
}
|
||||
|
||||
.helpful-link-icon {
|
||||
font-size: 1.5rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-404-number {
|
||||
font-size: 8rem;
|
||||
}
|
||||
|
||||
.error-404-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.error-404-message {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.error-404-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-404-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.helpful-links-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.error-404-number {
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
.error-404-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-404-illustration svg {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-404-container">
|
||||
<div class="error-404-content">
|
||||
<div class="error-404-number">404</div>
|
||||
|
||||
<div class="error-404-illustration">
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#003366;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#0066cc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#ffd700;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Magnifying glass -->
|
||||
<circle cx="80" cy="80" r="50" fill="none" stroke="url(#grad1)" stroke-width="4" opacity="0.6"/>
|
||||
<line x1="120" y1="120" x2="160" y2="160" stroke="url(#grad1)" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Question mark -->
|
||||
<path d="M 100 50 Q 100 40 110 40 Q 120 40 120 50 Q 120 60 110 60 L 110 80 Q 110 90 100 90"
|
||||
fill="none" stroke="url(#grad1)" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
|
||||
<circle cx="100" cy="110" r="3" fill="url(#grad1)" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="error-404-title">Страницата не е намерена</h1>
|
||||
|
||||
<p class="error-404-message">
|
||||
Съжаляваме, но страницата, която търсите, не съществува или е преместена.
|
||||
Моля, проверете адреса или използвайте навигацията по-долу, за да намерите това, което търсите.
|
||||
</p>
|
||||
|
||||
<div class="error-404-actions">
|
||||
<a href="{% url 'reports:home' %}" class="btn btn-primary">
|
||||
<span style="margin-right: 0.5rem;">🏠</span>
|
||||
Начална страница
|
||||
</a>
|
||||
<a href="javascript:history.back()" class="btn btn-secondary">
|
||||
<span style="margin-right: 0.5rem;">←</span>
|
||||
Назад
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="error-404-helpful-links">
|
||||
<h3>Полезни връзки</h3>
|
||||
<div class="helpful-links-grid">
|
||||
<a href="{% url 'reports:list' %}" class="helpful-link">
|
||||
<span class="helpful-link-icon">📋</span>
|
||||
Всички доклади
|
||||
</a>
|
||||
<a href="{% url 'reports:create' %}" class="helpful-link">
|
||||
<span class="helpful-link-icon">➕</span>
|
||||
Докладване на измама
|
||||
</a>
|
||||
<a href="{% url 'reports:search' %}" class="helpful-link">
|
||||
<span class="helpful-link-icon">🔍</span>
|
||||
Търсене
|
||||
</a>
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'accounts:profile' %}" class="helpful-link">
|
||||
<span class="helpful-link-icon">👤</span>
|
||||
Моят профил
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'accounts:login' %}" class="helpful-link">
|
||||
<span class="helpful-link-icon">🔐</span>
|
||||
Вход в системата
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
29
templates/accounts/login.html
Normal file
29
templates/accounts/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Вход - Официален Портал{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<h2>Вход в Системата</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="id_username">Потребителско Име</label>
|
||||
<input type="text" name="username" id="id_username" class="form-control" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_password">Парола</label>
|
||||
<input type="password" name="password" id="id_password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Вход</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<a href="{% url 'accounts:password_reset' %}">Забравена парола?</a>
|
||||
<span>|</span>
|
||||
<a href="{% url 'accounts:register' %}">Създаване на акаунт</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
25
templates/accounts/mfa_disable.html
Normal file
25
templates/accounts/mfa_disable.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Деактивиране на Двуфакторна Автентификация - Официален Портал{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h2>Деактивиране на Двуфакторна Автентификация</h2>
|
||||
<div class="gov-alert gov-alert-warning">
|
||||
<div class="alert-icon">⚠</div>
|
||||
<div class="alert-content">
|
||||
<p><strong>Внимание:</strong> Деактивирането на ДА ще намали сигурността на вашия акаунт.</p>
|
||||
<p>Сигурни ли сте, че искате да деактивирате двуфакторната автентификация?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" data-loading>
|
||||
{% csrf_token %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger" data-tooltip="Това ще деактивира двуфакторната автентификация">Да, Деактивирам ДА</button>
|
||||
<a href="{% url 'accounts:profile' %}" class="btn btn-secondary">Отказ</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user