This commit is contained in:
Iliyan Angelov
2025-11-26 22:32:20 +02:00
commit ed94dd22dd
150 changed files with 14058 additions and 0 deletions

67
.gitignore vendored Normal file
View 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

View 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
View 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
View 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
View 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
View File

47
accounts/admin.py Normal file
View 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
View 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
View 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
View 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'
)

View File

View File

View 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))

View 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.')
)

View 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'))

View 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
View 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

View 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')],
},
),
]

View 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),
),
]

View File

175
accounts/models.py Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

33
accounts/urls.py Normal file
View 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
View 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
View File

32
analytics/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'analytics'

View 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')],
},
),
]

View File

98
analytics/models.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
analytics/urls.py Normal file
View 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
View 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

View File

16
fraud_platform/asgi.py Normal file
View 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()

View 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,
}
}

View 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 *

View 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
}

View 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

View 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,
},
},
}

View 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
View 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
View 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
View File

47
legal/admin.py Normal file
View 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
View 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
View 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...'
}),
}

View 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')],
},
),
]

View File

210
legal/models.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
legal/urls.py Normal file
View 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
View 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
View 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
View File

32
moderation/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ModerationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'moderation'

View 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')],
},
),
]

View 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)'),
),
]

View File

145
moderation/models.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
moderation/urls.py Normal file
View 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
View 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
View File

246
osint/admin.py Normal file
View 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
View 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
View 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'}),
}

View File

@@ -0,0 +1,2 @@
# Management package

View File

@@ -0,0 +1,2 @@
# Management commands package

View 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}'
))

View 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')],
},
),
]

View File

@@ -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')},
),
]

View File

468
osint/models.py Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

35
osint/urls.py Normal file
View 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
View 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
View File

247
reports/admin.py Normal file
View 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
View 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
View 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
View 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': 'Ако имате документи, снимки или друга информация, която подкрепя вашата заявка, моля опишете я тук.',
}

View 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'),
),
]

View 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'),
),
]

View 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',
},
),
]

View File

@@ -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),
),
]

View 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')],
},
),
]

View File

@@ -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)'),
),
]

View File

411
reports/models.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

33
reports/urls.py Normal file
View 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
View 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
View 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
View 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 %}

View 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 %}

View 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