Merge pull request '001-build-an-application' (#1) from 001-build-an-application into main
Reviewed-on: https://codeberg.org/gurix/Reklamator/pulls/1
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Flask Configuration
|
||||
FLASK_APP=run.py
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=change-this-to-a-random-secret-key-in-production
|
||||
|
||||
# Claude API Configuration
|
||||
ANTHROPIC_API_KEY=your-claude-api-key-here
|
||||
|
||||
# ClamAV Configuration
|
||||
CLAMD_SOCKET=/var/run/clamav/clamd.ctl
|
||||
|
||||
# Application Configuration
|
||||
DATA_DIR=./data
|
||||
MAX_CONTENT_LENGTH=10485760
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_PER_HOUR=10
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Data directory (contains user-submitted feedback)
|
||||
data/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
+165
-35
@@ -1,50 +1,180 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
===================
|
||||
Version Change: 0.0.0 → 1.0.0
|
||||
Modified Principles: N/A (initial creation)
|
||||
Added Sections:
|
||||
- Core Principles (5 principles defined)
|
||||
- Quality Standards
|
||||
- Development Process
|
||||
- Governance
|
||||
Removed Sections: N/A
|
||||
Templates Status:
|
||||
✅ .specify/templates/spec-template.md - Verified alignment
|
||||
✅ .specify/templates/plan-template.md - Constitution Check section present
|
||||
✅ .specify/templates/tasks-template.md - Test-first approach reflected
|
||||
✅ .specify/templates/checklist-template.md - Generic template aligns
|
||||
✅ .specify/templates/agent-file-template.md - Generic template aligns
|
||||
Follow-up TODOs:
|
||||
- Consider adding specific technology stack constraints as project matures
|
||||
- Review slash command files for any agent-specific references
|
||||
===================
|
||||
-->
|
||||
|
||||
# Reklamator Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
### I. Specification-First Development
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
Every feature MUST begin with a complete specification document before any implementation work begins. Specifications MUST include:
|
||||
- Prioritized user stories that are independently testable
|
||||
- Functional requirements with unique identifiers (FR-001, etc.)
|
||||
- Measurable success criteria
|
||||
- Edge cases and boundary conditions
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
**Rationale**: Clear specifications prevent scope creep, enable accurate effort estimation, and provide a shared understanding between stakeholders and implementers. Independent testability ensures we can deliver incremental value.
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
### II. Test-First Discipline (NON-NEGOTIABLE)
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
Tests MUST be written before implementation code. The mandatory workflow is:
|
||||
1. Write tests that capture requirements
|
||||
2. Verify tests FAIL (proving they test something meaningful)
|
||||
3. Implement the minimum code to make tests pass
|
||||
4. Refactor while keeping tests green
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
**Rationale**: Test-first development forces clear thinking about requirements and interfaces. It prevents the common trap of writing tests that merely confirm what the code does rather than what it should do. This is non-negotiable because untested code is unmaintainable code.
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
### III. Independent User Stories
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
User stories MUST be designed as independently deliverable units of value. Each story:
|
||||
- Can be implemented without requiring other stories to be complete
|
||||
- Can be tested in isolation
|
||||
- Delivers tangible value to users even if it's the only story delivered
|
||||
- Has an explicitly assigned priority (P1, P2, P3, etc.)
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
**Rationale**: Independent stories enable incremental delivery, reduce risk, allow flexible prioritization, and support parallel development when team capacity allows.
|
||||
|
||||
### IV. Simplicity & Justification
|
||||
|
||||
Complexity MUST be justified. Default to the simplest solution that meets requirements. When introducing:
|
||||
- Additional abstraction layers
|
||||
- New dependencies
|
||||
- Design patterns beyond direct implementation
|
||||
- Additional projects or services
|
||||
|
||||
Document WHY it's needed and what simpler alternative was rejected and why.
|
||||
|
||||
**Rationale**: Complexity is expensive. It increases cognitive load, maintenance burden, bug surface area, and onboarding time. Every complexity decision should be a conscious tradeoff with documented reasoning.
|
||||
|
||||
### V. Documentation as Code
|
||||
|
||||
Documentation MUST live alongside code, be version-controlled, and follow the same review process. Required documentation:
|
||||
- Feature specifications in `/specs/[###-feature-name]/spec.md`
|
||||
- Implementation plans in `/specs/[###-feature-name]/plan.md`
|
||||
- Data models, contracts, and quickstart guides in feature directories
|
||||
- Constitution (this document) for governance
|
||||
|
||||
**Rationale**: Outdated documentation is worse than no documentation. Treating docs as code ensures they stay current, searchable, and reviewable. The Specify framework structure enforces this by design.
|
||||
|
||||
## Quality Standards
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- **Contract tests**: Required for all public APIs and interfaces
|
||||
- **Integration tests**: Required for user journeys and cross-component interactions
|
||||
- **Unit tests**: Optional but encouraged for complex logic
|
||||
- **Test independence**: Tests MUST NOT depend on execution order
|
||||
- **Test clarity**: Test names MUST describe what behavior is being verified
|
||||
|
||||
### Code Quality
|
||||
|
||||
- Clear, self-documenting code preferred over extensive comments
|
||||
- Comments required only for non-obvious decisions or complex algorithms
|
||||
- Linting and formatting tools MUST be configured and enforced
|
||||
- Error handling MUST be explicit and meaningful
|
||||
- Logging MUST be structured and include sufficient context
|
||||
|
||||
## Development Process
|
||||
|
||||
### Feature Lifecycle
|
||||
|
||||
1. **Specify** (`/speckit.specify`): Create feature specification with user stories
|
||||
2. **Clarify** (`/speckit.clarify`): Address any ambiguities or underspecified areas
|
||||
3. **Plan** (`/speckit.plan`): Research, design data models, define contracts
|
||||
4. **Tasks** (`/speckit.tasks`): Generate dependency-ordered implementation tasks
|
||||
5. **Implement** (`/speckit.implement`): Execute tasks following test-first discipline
|
||||
6. **Analyze** (`/speckit.analyze`): Verify cross-artifact consistency
|
||||
|
||||
### Bug Fix Protocol
|
||||
|
||||
Every bug fix MUST follow this protocol:
|
||||
|
||||
1. **Write a Failing Test**: Create a test that reproduces the bug and currently fails
|
||||
2. **Verify Failure**: Run the test to confirm it fails with the buggy code
|
||||
3. **Fix the Bug**: Implement the minimal fix to address the root cause
|
||||
4. **Verify Success**: Run the test to confirm it now passes
|
||||
5. **Document**: Add entry to `docs/TECHNICAL_DEBT.md` documenting:
|
||||
- Bug description and symptoms
|
||||
- Root cause analysis
|
||||
- Test coverage added
|
||||
- Lessons learned
|
||||
6. **Commit Together**: Test and fix MUST be committed in the same commit or immediately sequential commits
|
||||
|
||||
**Rationale**: Bug fixes without tests are incomplete. Tests serve as regression prevention and documentation of expected behavior. If a bug was found manually, it means our test coverage has a gap that must be filled.
|
||||
|
||||
**Exceptions**: The only acceptable reason to skip adding a test is if:
|
||||
- The bug is in test infrastructure itself
|
||||
- The bug requires external dependencies unavailable in CI (document in TECHNICAL_DEBT.md)
|
||||
- The bug is in a deprecated component being removed
|
||||
|
||||
In all exception cases, document the rationale in the commit message and TECHNICAL_DEBT.md.
|
||||
|
||||
### Branching & Integration
|
||||
|
||||
- Feature branches named `###-feature-name` where ### is numeric identifier
|
||||
- Branch created before planning phase begins
|
||||
- Regular integration to avoid long-lived feature branches
|
||||
- All changes require review before merging
|
||||
|
||||
### Constitution Compliance
|
||||
|
||||
Before starting implementation (Phase 0 research), run Constitution Check from `plan.md`. Any violations MUST be documented in the Complexity Tracking table with:
|
||||
- What rule is being violated
|
||||
- Why it's necessary for this feature
|
||||
- What simpler alternative was considered and rejected
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
### Amendment Process
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
1. **Proposal**: Document proposed change with rationale
|
||||
2. **Review**: Discuss impact on existing practices and templates
|
||||
3. **Sync**: Update all dependent templates and documentation
|
||||
4. **Version**: Increment version following semantic versioning
|
||||
5. **Ratify**: Record amendment date and version
|
||||
|
||||
### Versioning Policy
|
||||
|
||||
- **MAJOR (X.0.0)**: Backward incompatible changes, principle removals, fundamental redefinitions
|
||||
- **MINOR (x.Y.0)**: New principles added, sections expanded, new mandatory practices
|
||||
- **PATCH (x.y.Z)**: Clarifications, wording improvements, typo fixes, non-semantic changes
|
||||
|
||||
### Compliance Review
|
||||
|
||||
All feature specifications, plans, and implementations MUST be reviewed for constitutional compliance. Reviewers MUST verify:
|
||||
- Specification precedes implementation
|
||||
- Tests written before code
|
||||
- User stories are independently testable
|
||||
- Complexity is justified in Complexity Tracking table when needed
|
||||
- Required documentation is complete and current
|
||||
|
||||
### Conflicts & Precedence
|
||||
|
||||
This constitution supersedes all other development guidelines, practices, or conventions. When conflicts arise, this document governs. If this constitution is unclear or incomplete for a specific situation, propose an amendment rather than work around it.
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2025-10-14 | **Last Amended**: 2025-10-16
|
||||
|
||||
**Amendment History**:
|
||||
- **1.1.0** (2025-10-16): Added Bug Fix Protocol requiring tests for all bug fixes and documentation in TECHNICAL_DEBT.md
|
||||
- **1.0.0** (2025-10-14): Initial constitution ratified
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# reklamator Development Guidelines
|
||||
|
||||
Auto-generated from all feature plans. Last updated: 2025-10-15
|
||||
|
||||
## Active Technologies
|
||||
- Python 3.11+ + Flask (web framework), no CSS frameworks, no JavaScript libraries (001-build-an-application)
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
backend/
|
||||
frontend/
|
||||
tests/
|
||||
```
|
||||
|
||||
## Commands
|
||||
cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] pytest [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] ruff check .
|
||||
|
||||
## Code Style
|
||||
Python 3.11+: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 001-build-an-application: Added Python 3.11+ + Flask (web framework), no CSS frameworks, no JavaScript libraries
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
@@ -0,0 +1,282 @@
|
||||
# Reklamator - Anonymous Feedback Platform
|
||||
|
||||
Reklamator is a simple, secure anonymous feedback platform that allows users to submit feedback with AI-powered analysis and translation capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Anonymous Feedback Submission** - Users can submit text feedback and/or file attachments without authentication
|
||||
✅ **Multi-language Support** - Accepts feedback in 50+ languages with automatic language detection
|
||||
✅ **AI-Powered Analysis** - Automatic categorization, summarization, and translation using Claude AI
|
||||
✅ **Secure File Handling** - Virus scanning, file type validation, and size limits
|
||||
✅ **Product Owner Dashboard** - Authenticated access to view, filter, and manage feedback
|
||||
✅ **Rate Limiting** - Protection against submission abuse
|
||||
✅ **Privacy-First** - No IP address logging or session tracking for anonymous submissions
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- ClamAV (for virus scanning)
|
||||
- Anthropic API key (for AI analysis)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd reklamator
|
||||
```
|
||||
|
||||
2. Create and activate a virtual environment:
|
||||
```bash
|
||||
python -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 and set:
|
||||
# - SECRET_KEY (generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
||||
# - ANTHROPIC_API_KEY (get from https://console.anthropic.com/)
|
||||
```
|
||||
|
||||
5. Start ClamAV daemon:
|
||||
```bash
|
||||
sudo systemctl start clamav-daemon # Linux
|
||||
# Or brew services start clamav on macOS
|
||||
```
|
||||
|
||||
6. Initialize the database and create admin user:
|
||||
```bash
|
||||
python init_admin.py
|
||||
```
|
||||
|
||||
7. Run the application:
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:5000`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
reklamator/
|
||||
├── app/ # Application code
|
||||
│ ├── __init__.py # Flask app factory with logging and security
|
||||
│ ├── routes/ # Route blueprints
|
||||
│ │ ├── submission.py # Anonymous feedback submission
|
||||
│ │ ├── dashboard.py # Product owner dashboard
|
||||
│ │ ├── auth.py # Authentication
|
||||
│ │ └── admin.py # Admin routes (deferred for POC)
|
||||
│ ├── services/ # Business logic
|
||||
│ │ ├── feedback_storage.py # File-based storage
|
||||
│ │ ├── ai_analyzer.py # AI analysis interface
|
||||
│ │ └── auth.py # Authentication services
|
||||
│ ├── models/ # Domain models
|
||||
│ │ ├── user.py # User model
|
||||
│ │ ├── product.py # Product model
|
||||
│ │ └── feedback.py # Feedback model
|
||||
│ ├── templates/ # HTML templates (plain, no frameworks)
|
||||
│ └── utils/ # Utilities
|
||||
│ └── file_validator.py # File validation and virus scanning
|
||||
├── data/ # File-based storage (created at runtime)
|
||||
│ ├── products/ # Per-product data
|
||||
│ │ └── {product-id}/
|
||||
│ │ ├── config.yaml # Product configuration
|
||||
│ │ └── feedback/
|
||||
│ │ └── {feedback-id}/
|
||||
│ │ ├── metadata.yaml # Feedback metadata
|
||||
│ │ ├── content.txt # Original text
|
||||
│ │ ├── analysis.md # AI analysis report
|
||||
│ │ └── attachments/ # File attachments
|
||||
│ └── users.yaml # User database (YAML file)
|
||||
├── tests/ # Test suite
|
||||
│ ├── contract/ # Route contract tests
|
||||
│ ├── integration/ # Integration tests
|
||||
│ └── unit/ # Unit tests
|
||||
├── config/ # Configuration files
|
||||
│ ├── development.py # Development config
|
||||
│ ├── production.py # Production config (with HSTS)
|
||||
│ └── testing.py # Test config
|
||||
└── specs/ # Documentation and specifications
|
||||
└── 001-build-an-application/
|
||||
├── spec.md # Feature specification
|
||||
├── plan.md # Implementation plan
|
||||
├── tasks.md # Task breakdown
|
||||
└── quickstart.md # Developer guide
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### For End Users (Anonymous Feedback)
|
||||
|
||||
1. Navigate to `/submit/{product-slug}`
|
||||
2. Enter your feedback in any language (optional if attaching files)
|
||||
3. Optionally attach up to 3 files (max 10MB each)
|
||||
4. Submit - your feedback is completely anonymous
|
||||
|
||||
### For Product Owners (Dashboard)
|
||||
|
||||
1. Navigate to `/login`
|
||||
2. Log in with your credentials
|
||||
3. View feedback list at `/dashboard`
|
||||
4. Filter by category, status, language, or search keywords
|
||||
5. Click on feedback to view details and AI analysis
|
||||
6. Update status or manually trigger analysis
|
||||
|
||||
### For Administrators
|
||||
|
||||
Administrators have access to all products. User and product management is currently done via YAML files (see POC note below).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `SECRET_KEY` - Flask secret key (required in production)
|
||||
- `ANTHROPIC_API_KEY` - Claude API key for AI analysis
|
||||
- `DATA_DIR` - Data storage directory (default: `./data`)
|
||||
- `MAX_CONTENT_LENGTH` - Max upload size in bytes (default: 10MB)
|
||||
- `CLAMD_SOCKET` - ClamAV socket path (default: `/var/run/clamav/clamd.ctl`)
|
||||
- `RATE_LIMIT_ENABLED` - Enable rate limiting (default: `true`)
|
||||
- `RATE_LIMIT_PER_HOUR` - Submissions per hour per IP (default: `10`)
|
||||
|
||||
### Managing Products and Users (POC)
|
||||
|
||||
**Note**: User Story 4 (Product/Service Registration and Management) has been deferred for the proof-of-concept. Products and users are managed manually via YAML files.
|
||||
|
||||
#### Adding a Product
|
||||
|
||||
1. Create directory: `data/products/{product-id}/`
|
||||
2. Create `config.yaml`:
|
||||
```yaml
|
||||
product_id: my-product
|
||||
name: My Product
|
||||
submission_url_slug: my-product-feedback
|
||||
owner_language: en
|
||||
assigned_owner_ids:
|
||||
- owner1
|
||||
status: active
|
||||
```
|
||||
|
||||
#### Adding a User
|
||||
|
||||
Edit `data/users.yaml`:
|
||||
```yaml
|
||||
- user_id: user1
|
||||
username: productowner
|
||||
password_hash: $2b$12$... # Generate with bcrypt
|
||||
role: product_owner
|
||||
is_active: true
|
||||
product_ids:
|
||||
- my-product
|
||||
```
|
||||
|
||||
To generate a password hash:
|
||||
```python
|
||||
import bcrypt
|
||||
print(bcrypt.hashpw(b'password', bcrypt.gensalt()).decode())
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
# All tests
|
||||
pytest
|
||||
|
||||
# Contract tests only
|
||||
pytest tests/contract/
|
||||
|
||||
# Integration tests only
|
||||
pytest tests/integration/
|
||||
|
||||
# With coverage
|
||||
pytest --cov=app --cov-report=html
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Install development dependencies:
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
Run code quality checks:
|
||||
```bash
|
||||
# Linting
|
||||
ruff check .
|
||||
|
||||
# Formatting
|
||||
black .
|
||||
|
||||
# Type checking
|
||||
mypy app/
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
See [Deployment Guide](docs/deployment.md) for detailed instructions on:
|
||||
- ClamAV setup
|
||||
- Nginx reverse proxy configuration
|
||||
- HTTPS/TLS setup
|
||||
- Systemd service configuration
|
||||
- Security hardening
|
||||
|
||||
## Security Features
|
||||
|
||||
- ✅ CSRF protection on all POST routes (Flask-WTF)
|
||||
- ✅ Session cookie security flags (HttpOnly, Secure in production, SameSite)
|
||||
- ✅ HSTS headers in production (1 year, includeSubDomains)
|
||||
- ✅ Rate limiting on submissions (configurable, default 10/hour)
|
||||
- ✅ File upload validation (type, size, virus scanning)
|
||||
- ✅ Complete anonymity (no IP logging for submissions)
|
||||
- ✅ Structured logging (JSON format in production)
|
||||
- ✅ Environment variable validation on startup
|
||||
- ✅ Health check endpoint (`/health`)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public (No Authentication)
|
||||
- `GET /submit/{product_slug}` - Display submission form
|
||||
- `POST /submit/{product_slug}` - Submit feedback
|
||||
- `GET /health` - Health check endpoint
|
||||
|
||||
### Authenticated (Product Owners)
|
||||
- `GET /login` - Login page
|
||||
- `POST /login` - Process login
|
||||
- `GET /logout` - Logout
|
||||
- `GET /dashboard` - Feedback list with filters
|
||||
- `GET /feedback/{id}` - Feedback detail
|
||||
- `POST /feedback/{id}/status` - Update feedback status
|
||||
- `POST /feedback/{id}/analyze` - Manually trigger AI analysis
|
||||
- `GET /feedback/{id}/attachment/{filename}` - Download attachment
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Python 3.11+ with Flask 3.0
|
||||
- **AI**: Anthropic Claude API
|
||||
- **Security**: ClamAV, Flask-WTF (CSRF), Flask-Limiter (rate limiting)
|
||||
- **Authentication**: Flask-Login with bcrypt password hashing
|
||||
- **Storage**: File-based (YAML + Markdown, no database)
|
||||
- **Testing**: pytest with contract and integration tests
|
||||
- **Frontend**: Plain HTML with minimal inline CSS (no frameworks)
|
||||
|
||||
## License
|
||||
|
||||
[Add your license here]
|
||||
|
||||
## Contributing
|
||||
|
||||
[Add contribution guidelines here]
|
||||
|
||||
## Support
|
||||
|
||||
For issues, please open a GitHub issue or contact [your support email].
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
"""Flask application factory"""
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Flask, request
|
||||
from flask_login import LoginManager
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Custom JSON formatter for structured logging"""
|
||||
|
||||
def format(self, record):
|
||||
log_data = {
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'module': record.module,
|
||||
'function': record.funcName,
|
||||
'line': record.lineno,
|
||||
}
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_data['exception'] = self.formatException(record.exc_info)
|
||||
|
||||
# Add extra fields if present
|
||||
if hasattr(record, 'extra_data'):
|
||||
log_data.update(record.extra_data)
|
||||
|
||||
return json.dumps(log_data)
|
||||
|
||||
|
||||
def configure_logging(app):
|
||||
"""Configure structured logging for the application
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
# Remove default Flask handlers
|
||||
app.logger.handlers.clear()
|
||||
|
||||
# Create console handler
|
||||
console_handler = logging.StreamHandler()
|
||||
|
||||
if app.config.get('DEBUG'):
|
||||
# Human-readable format for development
|
||||
console_handler.setFormatter(logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
|
||||
))
|
||||
else:
|
||||
# JSON format for production
|
||||
console_handler.setFormatter(JSONFormatter())
|
||||
|
||||
console_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(console_handler)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
|
||||
# Log all requests
|
||||
@app.before_request
|
||||
def log_request():
|
||||
app.logger.info(
|
||||
f'Request: {request.method} {request.path}',
|
||||
extra={'extra_data': {
|
||||
'method': request.method,
|
||||
'path': request.path,
|
||||
'remote_addr': request.remote_addr,
|
||||
'user_agent': str(request.user_agent)
|
||||
}}
|
||||
)
|
||||
|
||||
# Log all responses and apply security headers
|
||||
@app.after_request
|
||||
def log_response(response):
|
||||
app.logger.info(
|
||||
f'Response: {response.status_code} for {request.method} {request.path}',
|
||||
extra={'extra_data': {
|
||||
'status_code': response.status_code,
|
||||
'method': request.method,
|
||||
'path': request.path
|
||||
}}
|
||||
)
|
||||
|
||||
# Apply security headers in production (T196)
|
||||
if not app.config.get('DEBUG'):
|
||||
if app.config.get('STRICT_TRANSPORT_SECURITY'):
|
||||
response.headers['Strict-Transport-Security'] = app.config['STRICT_TRANSPORT_SECURITY']
|
||||
if app.config.get('X_CONTENT_TYPE_OPTIONS'):
|
||||
response.headers['X-Content-Type-Options'] = app.config['X_CONTENT_TYPE_OPTIONS']
|
||||
if app.config.get('X_FRAME_OPTIONS'):
|
||||
response.headers['X-Frame-Options'] = app.config['X_FRAME_OPTIONS']
|
||||
if app.config.get('X_XSS_PROTECTION'):
|
||||
response.headers['X-XSS-Protection'] = app.config['X_XSS_PROTECTION']
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def validate_environment(app):
|
||||
"""Validate required environment variables on startup
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
|
||||
Raises:
|
||||
ValueError: If required environment variables are missing
|
||||
"""
|
||||
required_vars = []
|
||||
|
||||
if not app.config.get('DEBUG'): # Production requirements
|
||||
if not app.config.get('SECRET_KEY'):
|
||||
required_vars.append('SECRET_KEY')
|
||||
if not app.config.get('ANTHROPIC_API_KEY'):
|
||||
required_vars.append('ANTHROPIC_API_KEY')
|
||||
|
||||
if required_vars:
|
||||
raise ValueError(f"Missing required environment variables: {', '.join(required_vars)}")
|
||||
|
||||
app.logger.info("Environment validation passed")
|
||||
|
||||
|
||||
def create_app(config_name='development'):
|
||||
"""Create and configure the Flask application
|
||||
|
||||
Args:
|
||||
config_name: Configuration environment (development, production, testing)
|
||||
|
||||
Returns:
|
||||
Flask application instance
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
if config_name == 'production':
|
||||
from config.production import ProductionConfig
|
||||
app.config.from_object(ProductionConfig)
|
||||
elif config_name == 'testing':
|
||||
from config.testing import TestingConfig
|
||||
app.config.from_object(TestingConfig)
|
||||
else:
|
||||
from config.development import DevelopmentConfig
|
||||
app.config.from_object(DevelopmentConfig)
|
||||
|
||||
# Configure structured logging (T194)
|
||||
configure_logging(app)
|
||||
|
||||
# Validate environment variables (T209)
|
||||
try:
|
||||
validate_environment(app)
|
||||
except ValueError as e:
|
||||
app.logger.error(f"Environment validation failed: {e}")
|
||||
raise
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs(app.config['DATA_DIR'], exist_ok=True)
|
||||
|
||||
# Initialize Flask-WTF CSRF Protection
|
||||
csrf = CSRFProtect()
|
||||
csrf.init_app(app)
|
||||
|
||||
# Initialize Flask-Login
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user by ID for Flask-Login"""
|
||||
from app.models.user import User
|
||||
return User.get_by_id(user_id)
|
||||
|
||||
# Initialize Flask-Limiter
|
||||
limiter = Limiter(
|
||||
app=app,
|
||||
key_func=get_remote_address,
|
||||
storage_uri=app.config['RATELIMIT_STORAGE_URL'],
|
||||
default_limits=[f"{app.config['RATELIMIT_PER_HOUR']}/hour"] if app.config.get('RATELIMIT_ENABLED') else []
|
||||
)
|
||||
|
||||
# Register blueprints
|
||||
from app.routes import submission, dashboard, admin, auth
|
||||
app.register_blueprint(submission.bp)
|
||||
app.register_blueprint(dashboard.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(auth.bp)
|
||||
|
||||
# Set index route
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Welcome page"""
|
||||
from flask import render_template
|
||||
return render_template('index.html')
|
||||
|
||||
# Health check endpoint (T208)
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring
|
||||
|
||||
Returns:
|
||||
JSON response with application status
|
||||
"""
|
||||
from flask import jsonify
|
||||
health_status = {
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
'environment': 'production' if not app.config.get('DEBUG') else 'development'
|
||||
}
|
||||
|
||||
# Check critical dependencies
|
||||
try:
|
||||
# Check data directory is writable
|
||||
data_dir = app.config.get('DATA_DIR')
|
||||
if not os.path.exists(data_dir):
|
||||
health_status['status'] = 'unhealthy'
|
||||
health_status['error'] = f'Data directory {data_dir} does not exist'
|
||||
return jsonify(health_status), 503
|
||||
|
||||
# Check AI API key is configured
|
||||
if not app.config.get('ANTHROPIC_API_KEY'):
|
||||
health_status['status'] = 'degraded'
|
||||
health_status['warning'] = 'AI analysis unavailable: ANTHROPIC_API_KEY not configured'
|
||||
|
||||
return jsonify(health_status), 200
|
||||
|
||||
except Exception as e:
|
||||
health_status['status'] = 'unhealthy'
|
||||
health_status['error'] = str(e)
|
||||
app.logger.error(f'Health check failed: {e}')
|
||||
return jsonify(health_status), 503
|
||||
|
||||
# Register error handlers
|
||||
@app.errorhandler(403)
|
||||
def forbidden(e):
|
||||
"""Handle 403 Forbidden errors"""
|
||||
from flask import render_template
|
||||
app.logger.warning(f'403 Forbidden: {request.path} - {e.description}')
|
||||
return render_template('error_403.html'), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
"""Handle 404 Not Found errors"""
|
||||
from flask import render_template
|
||||
app.logger.warning(f'404 Not Found: {request.path} - {e.description}')
|
||||
return render_template('error_404.html'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
"""Handle 500 Internal Server errors"""
|
||||
from flask import render_template
|
||||
app.logger.error(f'500 Internal Server Error: {request.path}', exc_info=True)
|
||||
return render_template('error_500.html' if os.path.exists(
|
||||
os.path.join(app.template_folder, 'error_500.html')
|
||||
) else 'error_404.html'), 500
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Models package"""
|
||||
# Models are imported here for convenience
|
||||
from app.models.user import User
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.product import Product
|
||||
|
||||
__all__ = ['User', 'Feedback', 'Product']
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Feedback model"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
import yaml
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class Feedback:
|
||||
"""Feedback submission model
|
||||
|
||||
Attributes:
|
||||
feedback_id: Unique feedback identifier (UUID)
|
||||
product_id: Associated product ID
|
||||
submitted_at: Submission timestamp (ISO 8601)
|
||||
status: Feedback status ('new', 'analyzing', 'analyzed', 'analysis_failed', 'archived')
|
||||
content_preview: First 200 chars of feedback text
|
||||
has_attachments: Whether feedback has file attachments
|
||||
attachment_count: Number of attached files
|
||||
original_language: Detected language of feedback (set during analysis)
|
||||
category: Feedback category (set during analysis)
|
||||
"""
|
||||
|
||||
VALID_STATUSES = ['new', 'in_progress', 'resolved', 'closed', 'analyzing', 'analyzed', 'analysis_failed', 'archived']
|
||||
|
||||
def __init__(self, feedback_id, product_id, submitted_at=None, status='new',
|
||||
content_preview='', has_attachments=False, attachment_count=0,
|
||||
original_language=None, category=None):
|
||||
self.feedback_id = feedback_id
|
||||
self.product_id = product_id
|
||||
self.submitted_at = submitted_at or datetime.utcnow().isoformat()
|
||||
self.status = status
|
||||
self.content_preview = content_preview
|
||||
self.has_attachments = has_attachments
|
||||
self.attachment_count = attachment_count
|
||||
self.original_language = original_language
|
||||
self.category = category
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert feedback to dictionary
|
||||
|
||||
Returns:
|
||||
dict: Feedback metadata
|
||||
"""
|
||||
data = {
|
||||
'feedback_id': self.feedback_id,
|
||||
'product_id': self.product_id,
|
||||
'submitted_at': self.submitted_at,
|
||||
'status': self.status,
|
||||
'content_preview': self.content_preview,
|
||||
'has_attachments': self.has_attachments,
|
||||
'attachment_count': self.attachment_count
|
||||
}
|
||||
|
||||
if self.original_language:
|
||||
data['original_language'] = self.original_language
|
||||
|
||||
if self.category:
|
||||
data['category'] = self.category
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""Create feedback from dictionary
|
||||
|
||||
Args:
|
||||
data: Dictionary with feedback data
|
||||
|
||||
Returns:
|
||||
Feedback: Feedback instance
|
||||
"""
|
||||
return cls(
|
||||
feedback_id=data['feedback_id'],
|
||||
product_id=data['product_id'],
|
||||
submitted_at=data.get('submitted_at'),
|
||||
status=data.get('status', 'new'),
|
||||
content_preview=data.get('content_preview', ''),
|
||||
has_attachments=data.get('has_attachments', False),
|
||||
attachment_count=data.get('attachment_count', 0),
|
||||
original_language=data.get('original_language'),
|
||||
category=data.get('category')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_id():
|
||||
"""Generate unique feedback ID
|
||||
|
||||
Returns:
|
||||
str: UUID-based feedback ID
|
||||
"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@staticmethod
|
||||
def _get_feedback_dir(product_id, feedback_id):
|
||||
"""Get feedback directory path
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
|
||||
Returns:
|
||||
str: Path to feedback directory
|
||||
"""
|
||||
return os.path.join(
|
||||
current_app.config['DATA_DIR'],
|
||||
'products',
|
||||
product_id,
|
||||
'feedback',
|
||||
feedback_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_metadata_file(product_id, feedback_id):
|
||||
"""Get metadata file path
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
|
||||
Returns:
|
||||
str: Path to metadata.yaml
|
||||
"""
|
||||
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
||||
return os.path.join(feedback_dir, 'metadata.yaml')
|
||||
|
||||
@staticmethod
|
||||
def _get_content_file(product_id, feedback_id):
|
||||
"""Get content file path
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
|
||||
Returns:
|
||||
str: Path to content.txt
|
||||
"""
|
||||
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
||||
return os.path.join(feedback_dir, 'content.txt')
|
||||
|
||||
@staticmethod
|
||||
def _get_attachments_dir(product_id, feedback_id):
|
||||
"""Get attachments directory path
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
|
||||
Returns:
|
||||
str: Path to attachments directory
|
||||
"""
|
||||
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
||||
return os.path.join(feedback_dir, 'attachments')
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, product_id, feedback_id):
|
||||
"""Load feedback by ID
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
|
||||
Returns:
|
||||
Feedback or None: Feedback instance if found, None otherwise
|
||||
"""
|
||||
metadata_file = cls._get_metadata_file(product_id, feedback_id)
|
||||
|
||||
if not os.path.exists(metadata_file):
|
||||
return None
|
||||
|
||||
with open(metadata_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
return cls.from_dict(data)
|
||||
|
||||
@classmethod
|
||||
def get_all_for_product(cls, product_id):
|
||||
"""Get all feedback for a product
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
|
||||
Returns:
|
||||
list: List of Feedback instances, sorted by submitted_at (newest first)
|
||||
"""
|
||||
feedback_list = []
|
||||
feedback_base_dir = os.path.join(
|
||||
current_app.config['DATA_DIR'],
|
||||
'products',
|
||||
product_id,
|
||||
'feedback'
|
||||
)
|
||||
|
||||
if not os.path.exists(feedback_base_dir):
|
||||
return feedback_list
|
||||
|
||||
for feedback_id in os.listdir(feedback_base_dir):
|
||||
feedback_dir = os.path.join(feedback_base_dir, feedback_id)
|
||||
|
||||
if not os.path.isdir(feedback_dir):
|
||||
continue
|
||||
|
||||
feedback = cls.get_by_id(product_id, feedback_id)
|
||||
if feedback:
|
||||
feedback_list.append(feedback)
|
||||
|
||||
# Sort by submitted_at (newest first)
|
||||
feedback_list.sort(key=lambda f: f.submitted_at, reverse=True)
|
||||
|
||||
return feedback_list
|
||||
|
||||
def save_metadata(self):
|
||||
"""Save feedback metadata to filesystem"""
|
||||
feedback_dir = self._get_feedback_dir(self.product_id, self.feedback_id)
|
||||
os.makedirs(feedback_dir, exist_ok=True)
|
||||
|
||||
metadata_file = self._get_metadata_file(self.product_id, self.feedback_id)
|
||||
|
||||
with open(metadata_file, 'w') as f:
|
||||
yaml.dump(self.to_dict(), f, default_flow_style=False)
|
||||
|
||||
def get_content(self):
|
||||
"""Load feedback content text
|
||||
|
||||
Returns:
|
||||
str or None: Feedback content if exists, None otherwise
|
||||
"""
|
||||
content_file = self._get_content_file(self.product_id, self.feedback_id)
|
||||
|
||||
if not os.path.exists(content_file):
|
||||
return None
|
||||
|
||||
with open(content_file, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
def get_attachments(self):
|
||||
"""Get list of attachment filenames
|
||||
|
||||
Returns:
|
||||
list: List of attachment filenames
|
||||
"""
|
||||
attachments_dir = self._get_attachments_dir(self.product_id, self.feedback_id)
|
||||
|
||||
if not os.path.exists(attachments_dir):
|
||||
return []
|
||||
|
||||
return [f for f in os.listdir(attachments_dir)
|
||||
if os.path.isfile(os.path.join(attachments_dir, f))]
|
||||
|
||||
def validate_status(self):
|
||||
"""Validate feedback status
|
||||
|
||||
Returns:
|
||||
bool: True if status is valid, False otherwise
|
||||
"""
|
||||
return self.status in self.VALID_STATUSES
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
"""Result of AI-powered feedback analysis
|
||||
|
||||
Attributes:
|
||||
category: Feedback category (bug, feature_request, question, complaint, praise, other)
|
||||
original_language: Detected language code (e.g., 'en', 'de', 'fr')
|
||||
summary: Brief summary of feedback (1-2 sentences)
|
||||
translation: Feedback translated to target language
|
||||
raw_analysis: Full analysis text in markdown format
|
||||
"""
|
||||
category: str
|
||||
original_language: str
|
||||
summary: str
|
||||
translation: str
|
||||
raw_analysis: str
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Product model"""
|
||||
import os
|
||||
import yaml
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class Product:
|
||||
"""Product/Service model
|
||||
|
||||
Attributes:
|
||||
product_id: Unique product identifier
|
||||
name: Product/service name
|
||||
submission_url_slug: URL slug for submission form
|
||||
owner_language: Preferred language for product owner
|
||||
assigned_owner_ids: List of product owner user IDs
|
||||
status: Product status ('active' or 'archived')
|
||||
"""
|
||||
|
||||
def __init__(self, product_id, name, submission_url_slug, owner_language,
|
||||
assigned_owner_ids, status='active'):
|
||||
self.product_id = product_id
|
||||
self.name = name
|
||||
self.submission_url_slug = submission_url_slug
|
||||
self.owner_language = owner_language
|
||||
self.assigned_owner_ids = assigned_owner_ids or []
|
||||
self.status = status
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert product to dictionary
|
||||
|
||||
Returns:
|
||||
dict: Product data
|
||||
"""
|
||||
return {
|
||||
'product_id': self.product_id,
|
||||
'name': self.name,
|
||||
'submission_url_slug': self.submission_url_slug,
|
||||
'owner_language': self.owner_language,
|
||||
'assigned_owner_ids': self.assigned_owner_ids,
|
||||
'status': self.status
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""Create product from dictionary
|
||||
|
||||
Args:
|
||||
data: Dictionary with product data
|
||||
|
||||
Returns:
|
||||
Product: Product instance
|
||||
"""
|
||||
return cls(
|
||||
product_id=data['product_id'],
|
||||
name=data['name'],
|
||||
submission_url_slug=data['submission_url_slug'],
|
||||
owner_language=data['owner_language'],
|
||||
assigned_owner_ids=data.get('assigned_owner_ids', []),
|
||||
status=data.get('status', 'active')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_product_dir(product_id):
|
||||
"""Get product directory path
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
|
||||
Returns:
|
||||
str: Path to product directory
|
||||
"""
|
||||
return os.path.join(current_app.config['DATA_DIR'], 'products', product_id)
|
||||
|
||||
@staticmethod
|
||||
def _get_config_file(product_id):
|
||||
"""Get product config file path
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
|
||||
Returns:
|
||||
str: Path to config.yaml
|
||||
"""
|
||||
product_dir = Product._get_product_dir(product_id)
|
||||
return os.path.join(product_dir, 'config.yaml')
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, product_id):
|
||||
"""Load product by ID
|
||||
|
||||
Args:
|
||||
product_id: Product ID to load
|
||||
|
||||
Returns:
|
||||
Product or None: Product instance if found, None otherwise
|
||||
"""
|
||||
config_file = cls._get_config_file(product_id)
|
||||
|
||||
if not os.path.exists(config_file):
|
||||
return None
|
||||
|
||||
with open(config_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
return cls.from_dict(data)
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug):
|
||||
"""Load product by submission URL slug
|
||||
|
||||
Args:
|
||||
slug: Submission URL slug
|
||||
|
||||
Returns:
|
||||
Product or None: Product instance if found, None otherwise
|
||||
"""
|
||||
# Scan all product directories
|
||||
products_dir = os.path.join(current_app.config['DATA_DIR'], 'products')
|
||||
|
||||
if not os.path.exists(products_dir):
|
||||
return None
|
||||
|
||||
for product_id in os.listdir(products_dir):
|
||||
product_dir = os.path.join(products_dir, product_id)
|
||||
|
||||
if not os.path.isdir(product_dir):
|
||||
continue
|
||||
|
||||
config_file = os.path.join(product_dir, 'config.yaml')
|
||||
|
||||
if not os.path.exists(config_file):
|
||||
continue
|
||||
|
||||
with open(config_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data.get('submission_url_slug') == slug:
|
||||
return cls.from_dict(data)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
"""Get all products
|
||||
|
||||
Returns:
|
||||
list: List of Product instances
|
||||
"""
|
||||
products = []
|
||||
products_dir = os.path.join(current_app.config['DATA_DIR'], 'products')
|
||||
|
||||
if not os.path.exists(products_dir):
|
||||
return products
|
||||
|
||||
for product_id in os.listdir(products_dir):
|
||||
product = cls.get_by_id(product_id)
|
||||
if product:
|
||||
products.append(product)
|
||||
|
||||
return products
|
||||
|
||||
def save(self):
|
||||
"""Save product to filesystem"""
|
||||
product_dir = self._get_product_dir(self.product_id)
|
||||
os.makedirs(product_dir, exist_ok=True)
|
||||
|
||||
config_file = self._get_config_file(self.product_id)
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(self.to_dict(), f, default_flow_style=False)
|
||||
|
||||
def delete(self):
|
||||
"""Delete product (not implemented - use archive instead)"""
|
||||
raise NotImplementedError("Products should be archived, not deleted")
|
||||
|
||||
def is_active(self):
|
||||
"""Check if product is active
|
||||
|
||||
Returns:
|
||||
bool: True if status is 'active', False otherwise
|
||||
"""
|
||||
return self.status == 'active'
|
||||
|
||||
def is_archived(self):
|
||||
"""Check if product is archived
|
||||
|
||||
Returns:
|
||||
bool: True if status is 'archived', False otherwise
|
||||
"""
|
||||
return self.status == 'archived'
|
||||
@@ -0,0 +1,253 @@
|
||||
"""User model for authentication"""
|
||||
import os
|
||||
import yaml
|
||||
from flask_login import UserMixin
|
||||
import bcrypt
|
||||
|
||||
|
||||
class User(UserMixin):
|
||||
"""User model for product owners and administrators
|
||||
|
||||
Attributes:
|
||||
user_id: Unique user identifier
|
||||
username: Username for login
|
||||
email: User email address
|
||||
password_hash: Bcrypt hashed password
|
||||
role: User role ('product_owner' or 'administrator')
|
||||
product_ids: List of product IDs (for product_owner role)
|
||||
is_active: Whether user account is active
|
||||
"""
|
||||
|
||||
def __init__(self, user_id, username, email, password_hash, role, product_ids=None, is_active=True):
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password_hash = password_hash
|
||||
self.role = role
|
||||
self.product_ids = product_ids or []
|
||||
self._is_active = is_active
|
||||
|
||||
def get_id(self):
|
||||
"""Get user ID for Flask-Login"""
|
||||
return self.user_id
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Check if user account is active (Flask-Login property)"""
|
||||
return self._is_active
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
"""Check if user is authenticated"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
"""Check if user is anonymous"""
|
||||
return False
|
||||
|
||||
def check_password(self, password):
|
||||
"""Verify password against stored hash
|
||||
|
||||
Args:
|
||||
password: Plain text password to verify
|
||||
|
||||
Returns:
|
||||
bool: True if password matches, False otherwise
|
||||
"""
|
||||
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password):
|
||||
"""Hash password using bcrypt
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
str: Hashed password
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert user to dictionary for storage
|
||||
|
||||
Returns:
|
||||
dict: User data
|
||||
"""
|
||||
return {
|
||||
'user_id': self.user_id,
|
||||
'username': self.username,
|
||||
'email': self.email,
|
||||
'password_hash': self.password_hash,
|
||||
'role': self.role,
|
||||
'product_ids': self.product_ids,
|
||||
'is_active': self._is_active
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""Create user from dictionary
|
||||
|
||||
Args:
|
||||
data: Dictionary with user data
|
||||
|
||||
Returns:
|
||||
User: User instance
|
||||
"""
|
||||
return cls(
|
||||
user_id=data['user_id'],
|
||||
username=data['username'],
|
||||
email=data['email'],
|
||||
password_hash=data['password_hash'],
|
||||
role=data['role'],
|
||||
product_ids=data.get('product_ids', []),
|
||||
is_active=data.get('is_active', True)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_users_file():
|
||||
"""Get path to users YAML file
|
||||
|
||||
Returns:
|
||||
str: Path to users.yaml
|
||||
"""
|
||||
from flask import current_app
|
||||
return os.path.join(current_app.config['DATA_DIR'], 'users.yaml')
|
||||
|
||||
@staticmethod
|
||||
def _load_all_users():
|
||||
"""Load all users from storage
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of user_id -> user_data
|
||||
"""
|
||||
users_file = User._get_users_file()
|
||||
|
||||
if not os.path.exists(users_file):
|
||||
return {}
|
||||
|
||||
with open(users_file, 'r') as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get('users', {})
|
||||
|
||||
@staticmethod
|
||||
def _save_all_users(users_dict):
|
||||
"""Save all users to storage
|
||||
|
||||
Args:
|
||||
users_dict: Dictionary of user_id -> user_data
|
||||
"""
|
||||
users_file = User._get_users_file()
|
||||
os.makedirs(os.path.dirname(users_file), exist_ok=True)
|
||||
|
||||
with open(users_file, 'w') as f:
|
||||
yaml.dump({'users': users_dict}, f, default_flow_style=False)
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, user_id):
|
||||
"""Load user by ID
|
||||
|
||||
Args:
|
||||
user_id: User ID to load
|
||||
|
||||
Returns:
|
||||
User or None: User instance if found, None otherwise
|
||||
"""
|
||||
users = cls._load_all_users()
|
||||
user_data = users.get(user_id)
|
||||
|
||||
if user_data:
|
||||
return cls.from_dict(user_data)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_username(cls, username):
|
||||
"""Load user by username
|
||||
|
||||
Args:
|
||||
username: Username to search for
|
||||
|
||||
Returns:
|
||||
User or None: User instance if found, None otherwise
|
||||
"""
|
||||
users = cls._load_all_users()
|
||||
|
||||
for user_data in users.values():
|
||||
if user_data['username'] == username:
|
||||
return cls.from_dict(user_data)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
"""Get all users
|
||||
|
||||
Returns:
|
||||
list: List of User instances
|
||||
"""
|
||||
users = cls._load_all_users()
|
||||
return [cls.from_dict(data) for data in users.values()]
|
||||
|
||||
def save(self):
|
||||
"""Save user to storage"""
|
||||
users = self._load_all_users()
|
||||
users[self.user_id] = self.to_dict()
|
||||
self._save_all_users(users)
|
||||
|
||||
def delete(self):
|
||||
"""Delete user from storage"""
|
||||
users = self._load_all_users()
|
||||
if self.user_id in users:
|
||||
del users[self.user_id]
|
||||
self._save_all_users(users)
|
||||
|
||||
@classmethod
|
||||
def create(cls, username, email, password, role, product_ids=None):
|
||||
"""Create new user
|
||||
|
||||
Args:
|
||||
username: Username for login
|
||||
email: User email
|
||||
password: Plain text password
|
||||
role: User role ('product_owner' or 'administrator')
|
||||
product_ids: List of product IDs (for product_owner)
|
||||
|
||||
Returns:
|
||||
User: Created user instance
|
||||
|
||||
Raises:
|
||||
ValueError: If username already exists or role is invalid
|
||||
"""
|
||||
# Validate role
|
||||
if role not in ['product_owner', 'administrator']:
|
||||
raise ValueError(f"Invalid role: {role}")
|
||||
|
||||
# Check if username exists
|
||||
if cls.get_by_username(username):
|
||||
raise ValueError(f"Username already exists: {username}")
|
||||
|
||||
# Generate user ID
|
||||
users = cls._load_all_users()
|
||||
if users:
|
||||
max_id = max([int(uid.replace('usr_', '')) for uid in users.keys()])
|
||||
user_id = f"usr_{max_id + 1:04d}"
|
||||
else:
|
||||
user_id = "usr_0001"
|
||||
|
||||
# Hash password
|
||||
password_hash = cls.hash_password(password)
|
||||
|
||||
# Create user
|
||||
user = cls(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
role=role,
|
||||
product_ids=product_ids or [],
|
||||
is_active=True
|
||||
)
|
||||
|
||||
user.save()
|
||||
return user
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Routes package"""
|
||||
# Blueprints are imported here for registration in the app factory
|
||||
from app.routes import submission, dashboard, admin, auth
|
||||
|
||||
__all__ = ['submission', 'dashboard', 'admin', 'auth']
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Admin routes - administrator management"""
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
# Routes will be implemented in Phase 6 (User Story 4)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Authentication routes"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""User login page
|
||||
|
||||
GET: Display login form
|
||||
POST: Process login credentials
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
current_app.logger.warning('Login attempt with missing credentials')
|
||||
flash('Please provide both username and password', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
user = User.get_by_username(username)
|
||||
|
||||
if user and user.is_active and user.check_password(password):
|
||||
login_user(user)
|
||||
current_app.logger.info(f'User logged in successfully: {username} (role: {user.role})')
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
|
||||
# Redirect to dashboard for product owners and administrators
|
||||
return redirect(url_for('dashboard.list'))
|
||||
else:
|
||||
current_app.logger.warning(f'Failed login attempt for username: {username}')
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
username = current_user.username
|
||||
logout_user()
|
||||
current_app.logger.info(f'User logged out: {username}')
|
||||
flash('You have been logged out', 'info')
|
||||
return redirect(url_for('index'))
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Dashboard routes - product owner feedback management"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, abort, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from app.services.feedback_storage import FeedbackStorageService
|
||||
from app.services.ai_analyzer import ClaudeAnalyzer
|
||||
from app.models.product import Product
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
|
||||
def get_user_product_ids():
|
||||
"""Get list of product IDs accessible to current user
|
||||
|
||||
Returns:
|
||||
list: Product IDs or None for administrators (access to all)
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
return []
|
||||
|
||||
# Administrators have access to all products
|
||||
if current_user.role == 'administrator':
|
||||
return None # None means all products
|
||||
|
||||
# Product owners see only assigned products
|
||||
return current_user.product_ids
|
||||
|
||||
|
||||
def check_product_access(product_id):
|
||||
"""Check if current user has access to product
|
||||
|
||||
Args:
|
||||
product_id: Product ID to check
|
||||
|
||||
Returns:
|
||||
bool: True if user has access, False otherwise
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Administrators have access to all products
|
||||
if current_user.role == 'administrator':
|
||||
return True
|
||||
|
||||
# Product owners see only assigned products
|
||||
return product_id in current_user.product_ids
|
||||
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def list():
|
||||
"""Dashboard - list feedback with filters and search
|
||||
|
||||
Query parameters:
|
||||
page: Page number (default 1)
|
||||
category: Filter by category
|
||||
status: Filter by status
|
||||
language: Filter by language
|
||||
search: Search query
|
||||
"""
|
||||
try:
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
category = request.args.get('category')
|
||||
status = request.args.get('status')
|
||||
language = request.args.get('language')
|
||||
search_query = request.args.get('search')
|
||||
|
||||
# Build filters
|
||||
filters = {}
|
||||
if category:
|
||||
filters['category'] = category
|
||||
if status:
|
||||
filters['status'] = status
|
||||
if language:
|
||||
filters['language'] = language
|
||||
|
||||
# Get product IDs for current user
|
||||
product_ids = get_user_product_ids()
|
||||
|
||||
current_app.logger.info(f'Dashboard accessed by {current_user.username} (page={page}, filters={filters})')
|
||||
|
||||
# Load feedback list
|
||||
result = FeedbackStorageService.load_feedback_list(
|
||||
product_ids=product_ids,
|
||||
page=page,
|
||||
per_page=50,
|
||||
filters=filters if filters else None,
|
||||
search_query=search_query
|
||||
)
|
||||
|
||||
# Load product names for display
|
||||
all_products = Product.get_all()
|
||||
product_names = {p.product_id: p.name for p in all_products}
|
||||
|
||||
return render_template(
|
||||
'dashboard/list.html',
|
||||
feedback_list=result['items'],
|
||||
page=result['page'],
|
||||
pages=result['pages'],
|
||||
total=result['total'],
|
||||
product_names=product_names,
|
||||
filters={
|
||||
'category': category,
|
||||
'status': status,
|
||||
'language': language,
|
||||
'search': search_query
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error loading dashboard for {current_user.username}: {e}', exc_info=True)
|
||||
abort(500)
|
||||
|
||||
|
||||
@bp.route('/feedback/<feedback_id>')
|
||||
@login_required
|
||||
def detail(feedback_id):
|
||||
"""Feedback detail view
|
||||
|
||||
Args:
|
||||
feedback_id: Feedback ID to view
|
||||
|
||||
Returns:
|
||||
Rendered template or 403/404 error
|
||||
"""
|
||||
# First check if feedback exists globally (to distinguish 403 from 404)
|
||||
all_products = Product.get_all()
|
||||
feedback_data = None
|
||||
actual_product_id = None
|
||||
|
||||
for product in all_products:
|
||||
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
||||
if feedback_data:
|
||||
actual_product_id = product.product_id
|
||||
break
|
||||
|
||||
# If not found globally, return 404
|
||||
if not feedback_data:
|
||||
abort(404)
|
||||
|
||||
# Check if user has access to this product
|
||||
if not check_product_access(actual_product_id):
|
||||
abort(403)
|
||||
|
||||
# Load product info
|
||||
product = Product.get_by_id(actual_product_id)
|
||||
|
||||
# Check if analysis exists
|
||||
has_analysis = FeedbackStorageService.has_analysis(actual_product_id, feedback_id)
|
||||
|
||||
# Check if feedback can be analyzed (has text content)
|
||||
can_analyze = bool(feedback_data.get('content'))
|
||||
|
||||
return render_template(
|
||||
'dashboard/detail.html',
|
||||
feedback=feedback_data,
|
||||
product=product,
|
||||
has_analysis=has_analysis,
|
||||
can_analyze=can_analyze
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/feedback/<feedback_id>/status', methods=['POST'])
|
||||
@login_required
|
||||
def update_status(feedback_id):
|
||||
"""Update feedback status
|
||||
|
||||
Args:
|
||||
feedback_id: Feedback ID to update
|
||||
|
||||
Returns:
|
||||
Redirect to detail page or error
|
||||
"""
|
||||
new_status = request.form.get('status')
|
||||
|
||||
if not new_status:
|
||||
flash('Status is required', 'error')
|
||||
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
||||
|
||||
# Find feedback globally first
|
||||
all_products = Product.get_all()
|
||||
actual_product_id = None
|
||||
|
||||
for product in all_products:
|
||||
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
||||
if feedback_data:
|
||||
actual_product_id = product.product_id
|
||||
break
|
||||
|
||||
# If not found globally, return 404
|
||||
if not actual_product_id:
|
||||
abort(404)
|
||||
|
||||
# Check if user has access to this product
|
||||
if not check_product_access(actual_product_id):
|
||||
abort(403)
|
||||
|
||||
# Update status
|
||||
success = FeedbackStorageService.update_feedback_status_by_id(
|
||||
actual_product_id, feedback_id, new_status
|
||||
)
|
||||
|
||||
if success:
|
||||
flash(f'Status updated to {new_status}', 'success')
|
||||
else:
|
||||
flash('Failed to update status', 'error')
|
||||
|
||||
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
||||
|
||||
|
||||
@bp.route('/feedback/<feedback_id>/analyze', methods=['POST'])
|
||||
@login_required
|
||||
def trigger_analysis(feedback_id):
|
||||
"""Manually trigger AI analysis for feedback
|
||||
|
||||
Args:
|
||||
feedback_id: Feedback ID to analyze
|
||||
|
||||
Returns:
|
||||
Redirect to detail page with flash message
|
||||
"""
|
||||
# Find feedback globally first
|
||||
all_products = Product.get_all()
|
||||
actual_product_id = None
|
||||
feedback_data = None
|
||||
|
||||
for product in all_products:
|
||||
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
||||
if feedback_data:
|
||||
actual_product_id = product.product_id
|
||||
break
|
||||
|
||||
# If not found globally, return 404
|
||||
if not actual_product_id or not feedback_data:
|
||||
abort(404)
|
||||
|
||||
# Check if user has access to this product
|
||||
if not check_product_access(actual_product_id):
|
||||
abort(403)
|
||||
|
||||
# Check if feedback has text content
|
||||
if not feedback_data.get('content'):
|
||||
flash('Cannot analyze feedback without text content', 'error')
|
||||
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
||||
|
||||
# Get product info for owner language
|
||||
product = Product.get_by_id(actual_product_id)
|
||||
|
||||
try:
|
||||
# Update status to "analyzing"
|
||||
FeedbackStorageService.update_feedback_status_by_id(
|
||||
actual_product_id, feedback_id, 'analyzing'
|
||||
)
|
||||
|
||||
# Get API key from app configuration
|
||||
api_key = current_app.config.get('ANTHROPIC_API_KEY')
|
||||
|
||||
if not api_key:
|
||||
raise Exception("ANTHROPIC_API_KEY not configured in app settings")
|
||||
|
||||
# Initialize analyzer
|
||||
analyzer = ClaudeAnalyzer(api_key=api_key)
|
||||
|
||||
# Analyze feedback
|
||||
result = analyzer.analyze_feedback(
|
||||
feedback_text=feedback_data['content'],
|
||||
target_language=product.owner_language,
|
||||
product_id=actual_product_id
|
||||
)
|
||||
|
||||
# Save analysis results
|
||||
FeedbackStorageService.save_analysis(actual_product_id, feedback_id, result)
|
||||
|
||||
# Update status to "analyzed"
|
||||
FeedbackStorageService.update_feedback_status_by_id(
|
||||
actual_product_id, feedback_id, 'analyzed'
|
||||
)
|
||||
|
||||
flash('Analysis completed successfully', 'success')
|
||||
|
||||
except Exception as e:
|
||||
# Update status to "analysis_failed" on error
|
||||
FeedbackStorageService.update_feedback_status_by_id(
|
||||
actual_product_id, feedback_id, 'analysis_failed'
|
||||
)
|
||||
current_app.logger.error(f"Manual analysis failed for feedback {feedback_id}: {e}")
|
||||
flash(f'Analysis failed: {str(e)}', 'error')
|
||||
|
||||
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
||||
|
||||
|
||||
@bp.route('/feedback/<feedback_id>/attachment/<filename>')
|
||||
@login_required
|
||||
def download_attachment(feedback_id, filename):
|
||||
"""Download attachment file
|
||||
|
||||
Args:
|
||||
feedback_id: Feedback ID
|
||||
filename: Attachment filename
|
||||
|
||||
Returns:
|
||||
File download or error
|
||||
"""
|
||||
# Find feedback globally first
|
||||
all_products = Product.get_all()
|
||||
actual_product_id = None
|
||||
|
||||
for product in all_products:
|
||||
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
||||
if feedback_data:
|
||||
actual_product_id = product.product_id
|
||||
break
|
||||
|
||||
# If not found globally, return 404
|
||||
if not actual_product_id:
|
||||
abort(404)
|
||||
|
||||
# Check if user has access to this product
|
||||
if not check_product_access(actual_product_id):
|
||||
abort(403)
|
||||
|
||||
# Get attachment path
|
||||
attachment_path = FeedbackStorageService.get_attachment_path(
|
||||
actual_product_id, feedback_id, filename
|
||||
)
|
||||
|
||||
if not attachment_path:
|
||||
abort(404)
|
||||
|
||||
# Detect MIME type
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
# Send file
|
||||
return send_file(
|
||||
attachment_path,
|
||||
mimetype=mime_type,
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Submission routes - anonymous feedback submission"""
|
||||
import threading
|
||||
import os
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort, current_app
|
||||
from app.models.product import Product
|
||||
from app.services.feedback_storage import FeedbackStorageService
|
||||
from app.services.ai_analyzer import ClaudeAnalyzer
|
||||
from app.utils.file_validator import validate_file, scan_file_for_viruses
|
||||
|
||||
|
||||
bp = Blueprint('submission', __name__, url_prefix='/submit')
|
||||
|
||||
|
||||
@bp.route('/<product_slug>', methods=['GET'])
|
||||
def form(product_slug):
|
||||
"""Display feedback submission form
|
||||
|
||||
Args:
|
||||
product_slug: Product submission URL slug
|
||||
|
||||
Returns:
|
||||
Rendered submission form template or 404
|
||||
"""
|
||||
try:
|
||||
# Load product by slug
|
||||
product = Product.get_by_slug(product_slug)
|
||||
|
||||
if not product:
|
||||
current_app.logger.warning(f'Product not found: {product_slug}')
|
||||
abort(404, description="Product not found")
|
||||
|
||||
# Check if product is archived
|
||||
if product.is_archived():
|
||||
current_app.logger.info(f'Attempt to access archived product: {product_slug}')
|
||||
abort(404, description="This product is no longer accepting feedback")
|
||||
|
||||
current_app.logger.info(f'Displaying submission form for product: {product_slug}')
|
||||
return render_template('submission/form.html', product=product)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error displaying submission form for {product_slug}: {e}', exc_info=True)
|
||||
abort(500)
|
||||
|
||||
|
||||
@bp.route('/<product_slug>', methods=['POST'])
|
||||
def submit(product_slug):
|
||||
"""Process feedback submission
|
||||
|
||||
Args:
|
||||
product_slug: Product submission URL slug
|
||||
|
||||
Returns:
|
||||
Redirect to success page or error page
|
||||
"""
|
||||
# Load product by slug
|
||||
product = Product.get_by_slug(product_slug)
|
||||
|
||||
if not product:
|
||||
current_app.logger.warning(f'Submission attempt for non-existent product: {product_slug}')
|
||||
abort(404, description="Product not found")
|
||||
|
||||
# Check if product is archived
|
||||
if product.is_archived():
|
||||
current_app.logger.warning(f'Submission attempt for archived product: {product_slug}')
|
||||
abort(404, description="This product is no longer accepting feedback")
|
||||
|
||||
# Get form data
|
||||
feedback_text = request.form.get('feedback_text', '').strip()
|
||||
|
||||
# Get uploaded files
|
||||
uploaded_files = request.files.getlist('files')
|
||||
# Filter out empty file inputs
|
||||
files = [f for f in uploaded_files if f and f.filename != '']
|
||||
|
||||
# Validation: Must provide either text or files
|
||||
if not feedback_text and not files:
|
||||
current_app.logger.info(f'Submission rejected: no content provided for {product_slug}')
|
||||
abort(400, description="Please provide either feedback text or attachments")
|
||||
|
||||
# Validation: Maximum 3 files
|
||||
if len(files) > 3:
|
||||
current_app.logger.warning(f'Submission rejected: too many files ({len(files)}) for {product_slug}')
|
||||
abort(400, description="Maximum 3 attachments allowed")
|
||||
|
||||
# Validate each file
|
||||
for file in files:
|
||||
is_valid, error_message = validate_file(file)
|
||||
if not is_valid:
|
||||
current_app.logger.warning(f'File validation failed for {product_slug}: {error_message}')
|
||||
abort(400, description=error_message)
|
||||
|
||||
# Scan for viruses
|
||||
is_clean, virus_message = scan_file_for_viruses(file)
|
||||
if not is_clean:
|
||||
current_app.logger.warning(f'Virus scan failed for {product_slug}: {virus_message}')
|
||||
abort(400, description=f"File rejected: {virus_message}")
|
||||
|
||||
# Save feedback (wrap only the save operation in try/except)
|
||||
try:
|
||||
feedback = FeedbackStorageService.save_complete_feedback(
|
||||
product_id=product.product_id,
|
||||
content_text=feedback_text if feedback_text else None,
|
||||
files=files if files else None
|
||||
)
|
||||
|
||||
current_app.logger.info(f'Feedback submitted successfully for {product_slug}: {feedback.feedback_id}')
|
||||
|
||||
# Trigger background analysis (T084, T085)
|
||||
if feedback_text: # Only analyze if there's text content
|
||||
_trigger_background_analysis(feedback, feedback_text, product)
|
||||
|
||||
return render_template('submission/success.html',
|
||||
product=product,
|
||||
feedback_id=feedback.feedback_id)
|
||||
|
||||
except Exception as e:
|
||||
# Log error
|
||||
current_app.logger.error(f"Error saving feedback for {product_slug}: {e}", exc_info=True)
|
||||
|
||||
return render_template('submission/error.html',
|
||||
product=product,
|
||||
error_message="An error occurred while saving your feedback. Please try again."), 500
|
||||
|
||||
|
||||
def _trigger_background_analysis(feedback, feedback_text, product):
|
||||
"""Trigger background AI analysis task (T084)
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance
|
||||
feedback_text: Feedback text content
|
||||
product: Product instance
|
||||
"""
|
||||
# Get the current app instance to pass to background thread
|
||||
app = current_app._get_current_object()
|
||||
|
||||
# Run analysis in background thread
|
||||
thread = threading.Thread(
|
||||
target=_analyze_feedback_background,
|
||||
args=(app, feedback.product_id, feedback.feedback_id, feedback_text, product.owner_language)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
|
||||
def _analyze_feedback_background(app, product_id, feedback_id, feedback_text, target_language):
|
||||
"""Background task for AI analysis (T086-T088)
|
||||
|
||||
This runs in a separate thread to avoid blocking the submission response.
|
||||
|
||||
Args:
|
||||
app: Flask app instance for application context
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
feedback_text: Feedback text to analyze
|
||||
target_language: Target language for translation
|
||||
"""
|
||||
# Run within Flask application context
|
||||
with app.app_context():
|
||||
try:
|
||||
# Update status to "analyzing" (T086)
|
||||
FeedbackStorageService.update_feedback_status_by_id(
|
||||
product_id, feedback_id, 'analyzing'
|
||||
)
|
||||
|
||||
# Get API key from app configuration
|
||||
api_key = current_app.config.get('ANTHROPIC_API_KEY')
|
||||
|
||||
if not api_key:
|
||||
raise Exception("ANTHROPIC_API_KEY not configured in app settings")
|
||||
|
||||
# Initialize analyzer
|
||||
analyzer = ClaudeAnalyzer(api_key=api_key)
|
||||
|
||||
# Analyze feedback
|
||||
result = analyzer.analyze_feedback(
|
||||
feedback_text=feedback_text,
|
||||
target_language=target_language,
|
||||
product_id=product_id
|
||||
)
|
||||
|
||||
# Save analysis results
|
||||
FeedbackStorageService.save_analysis(product_id, feedback_id, result)
|
||||
|
||||
# Update status to "analyzed" (T087)
|
||||
FeedbackStorageService.update_feedback_status_by_id(
|
||||
product_id, feedback_id, 'analyzed'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Update status to "analysis_failed" on error (T088)
|
||||
FeedbackStorageService.update_feedback_status_by_id(
|
||||
product_id, feedback_id, 'analysis_failed'
|
||||
)
|
||||
# Log error (T193)
|
||||
app.logger.error(f"Background analysis failed for feedback {feedback_id}: {e}", exc_info=True)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Services package"""
|
||||
# Services provide business logic and external integrations
|
||||
@@ -0,0 +1,252 @@
|
||||
"""AI-powered feedback analysis service"""
|
||||
from abc import ABC, abstractmethod
|
||||
import anthropic
|
||||
import re
|
||||
import time
|
||||
from app.models.feedback import AnalysisResult
|
||||
|
||||
|
||||
class AIAnalyzer(ABC):
|
||||
"""Abstract base class for AI-powered feedback analyzers
|
||||
|
||||
Subclasses must implement the analyze_feedback method to provide
|
||||
categorization, summarization, and translation capabilities.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def analyze_feedback(self, feedback_text, target_language, product_id):
|
||||
"""Analyze feedback using AI
|
||||
|
||||
Args:
|
||||
feedback_text: The feedback text to analyze
|
||||
target_language: Language code for translation (e.g., 'en', 'de')
|
||||
product_id: Product ID for context
|
||||
|
||||
Returns:
|
||||
AnalysisResult: Analysis results including category, summary, and translation
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ClaudeAnalyzer(AIAnalyzer):
|
||||
"""Claude AI-based feedback analyzer using Anthropic API
|
||||
|
||||
Uses Claude to analyze feedback and extract:
|
||||
- Category (bug, feature_request, question, complaint, praise, other)
|
||||
- Original language detection
|
||||
- Summary (1-2 sentences)
|
||||
- Translation to target language
|
||||
"""
|
||||
|
||||
# Valid feedback categories
|
||||
VALID_CATEGORIES = ['bug', 'feature_request', 'question', 'complaint', 'praise', 'other']
|
||||
|
||||
def __init__(self, api_key):
|
||||
"""Initialize Claude analyzer
|
||||
|
||||
Args:
|
||||
api_key: Anthropic API key
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
def analyze_feedback(self, feedback_text, target_language, product_id):
|
||||
"""Analyze feedback using Claude API
|
||||
|
||||
Args:
|
||||
feedback_text: The feedback text to analyze
|
||||
target_language: Language code for translation (e.g., 'en', 'de')
|
||||
product_id: Product ID for context
|
||||
|
||||
Returns:
|
||||
AnalysisResult: Analysis results
|
||||
|
||||
Raises:
|
||||
Exception: If API call fails or timeout occurs
|
||||
"""
|
||||
# Design prompt for Claude API (T075)
|
||||
prompt = self._build_analysis_prompt(feedback_text, target_language)
|
||||
|
||||
# Retry logic for transient failures (T081)
|
||||
max_retries = 3
|
||||
retry_delay = 1 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Call Claude API with timeout (T074, T080)
|
||||
response = self._call_claude_api(prompt, timeout=45)
|
||||
|
||||
# Extract analysis components from response
|
||||
raw_analysis = response.content[0].text
|
||||
|
||||
# Extract category (T077)
|
||||
category = self._extract_category(raw_analysis)
|
||||
|
||||
# Detect original language (T076)
|
||||
original_language = self._extract_language(raw_analysis)
|
||||
|
||||
# Extract summary (T078)
|
||||
summary = self._extract_summary(raw_analysis)
|
||||
|
||||
# Extract translation (T079)
|
||||
translation = self._extract_translation(raw_analysis)
|
||||
|
||||
return AnalysisResult(
|
||||
category=category,
|
||||
original_language=original_language,
|
||||
summary=summary,
|
||||
translation=translation,
|
||||
raw_analysis=raw_analysis
|
||||
)
|
||||
|
||||
except anthropic.APITimeoutError as e:
|
||||
# Handle API timeouts (T080) - don't retry timeouts
|
||||
raise Exception(f"Claude API timeout after 45s: {str(e)}")
|
||||
|
||||
except anthropic.APIError as e:
|
||||
# Handle API errors with retry logic (T081)
|
||||
# Check if this is a transient error (rate limit, server error)
|
||||
error_type = type(e).__name__
|
||||
is_retryable = any(x in error_type.lower() for x in ['ratelimit', 'server', 'unavailable', 'overloaded'])
|
||||
|
||||
if is_retryable and attempt < max_retries - 1:
|
||||
# Wait before retrying with exponential backoff
|
||||
wait_time = retry_delay * (2 ** attempt)
|
||||
time.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
# Not retryable or max retries exceeded
|
||||
raise Exception(f"Claude API error: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
# General error handling - don't retry
|
||||
raise Exception(f"Analysis failed: {str(e)}")
|
||||
|
||||
def _build_analysis_prompt(self, feedback_text, target_language):
|
||||
"""Build the analysis prompt for Claude
|
||||
|
||||
Prompt design (T075): Single call to categorize, summarize, and translate
|
||||
"""
|
||||
return f"""Analyze the following user feedback and provide a structured analysis.
|
||||
|
||||
User Feedback:
|
||||
{feedback_text}
|
||||
|
||||
Please provide your analysis in the following format:
|
||||
|
||||
# Feedback Analysis
|
||||
|
||||
**Category**: [Choose ONE: bug, feature_request, question, complaint, praise, other]
|
||||
|
||||
**Original Language**: [Detect the language code, e.g., en, de, fr, es]
|
||||
|
||||
**Summary**: [Provide a concise 1-2 sentence summary of the feedback]
|
||||
|
||||
**Translation**: [Translate the feedback to {target_language}. If already in {target_language}, write "(same as original)"]
|
||||
|
||||
Important:
|
||||
- Be accurate in language detection
|
||||
- Choose the most appropriate category
|
||||
- Keep the summary brief but informative
|
||||
- Translate naturally and accurately"""
|
||||
|
||||
def _call_claude_api(self, prompt, timeout=45):
|
||||
"""Call Claude API with proper configuration
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send to Claude
|
||||
timeout: Timeout in seconds (default 45s per T080)
|
||||
|
||||
Returns:
|
||||
API response object
|
||||
|
||||
Raises:
|
||||
anthropic.APITimeoutError: If request times out
|
||||
anthropic.APIError: If API returns an error
|
||||
"""
|
||||
return self.client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=1000,
|
||||
timeout=timeout,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def _extract_category(self, analysis_text):
|
||||
"""Extract category from analysis text (T077)
|
||||
|
||||
Args:
|
||||
analysis_text: Raw analysis markdown
|
||||
|
||||
Returns:
|
||||
str: Category (defaults to 'other' if not found or invalid)
|
||||
"""
|
||||
# Look for pattern: **Category**: bug
|
||||
match = re.search(r'\*\*Category\*\*:\s*(\w+)', analysis_text, re.IGNORECASE)
|
||||
|
||||
if match:
|
||||
category = match.group(1).lower()
|
||||
# Validate category
|
||||
if category in self.VALID_CATEGORIES:
|
||||
return category
|
||||
|
||||
# Default to 'other' if not found or invalid
|
||||
return 'other'
|
||||
|
||||
def _extract_language(self, analysis_text):
|
||||
"""Extract detected language from analysis text (T076)
|
||||
|
||||
Args:
|
||||
analysis_text: Raw analysis markdown
|
||||
|
||||
Returns:
|
||||
str: Language code (defaults to 'unknown' if not found)
|
||||
"""
|
||||
# Look for pattern: **Original Language**: en
|
||||
match = re.search(r'\*\*Original Language\*\*:\s*(\w+)', analysis_text, re.IGNORECASE)
|
||||
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# Default to 'unknown'
|
||||
return 'unknown'
|
||||
|
||||
def _extract_summary(self, analysis_text):
|
||||
"""Extract summary from analysis text (T078)
|
||||
|
||||
Args:
|
||||
analysis_text: Raw analysis markdown
|
||||
|
||||
Returns:
|
||||
str: Summary text (defaults to empty string if not found)
|
||||
"""
|
||||
# Look for pattern: **Summary**: [text]
|
||||
match = re.search(r'\*\*Summary\*\*:\s*(.+?)(?=\n\*\*|\n\n|$)', analysis_text, re.IGNORECASE | re.DOTALL)
|
||||
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Default to empty string
|
||||
return ''
|
||||
|
||||
def _extract_translation(self, analysis_text):
|
||||
"""Extract translation from analysis text (T079)
|
||||
|
||||
Args:
|
||||
analysis_text: Raw analysis markdown
|
||||
|
||||
Returns:
|
||||
str: Translated text (defaults to empty string if not found)
|
||||
"""
|
||||
# Look for pattern: **Translation**: [text]
|
||||
match = re.search(r'\*\*Translation\*\*:\s*(.+?)(?=\n\*\*|\n\n|$)', analysis_text, re.IGNORECASE | re.DOTALL)
|
||||
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Default to empty string
|
||||
return ''
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Authentication service"""
|
||||
import bcrypt
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def verify_credentials(username, password):
|
||||
"""Verify username and password
|
||||
|
||||
Args:
|
||||
username: Username to check
|
||||
password: Plain text password to verify
|
||||
|
||||
Returns:
|
||||
User or None: User object if credentials valid, None otherwise
|
||||
"""
|
||||
if not username or not password:
|
||||
return None
|
||||
|
||||
user = User.get_by_username(username)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
|
||||
if user.check_password(password):
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash password using bcrypt
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
str: Hashed password
|
||||
"""
|
||||
return User.hash_password(password)
|
||||
|
||||
|
||||
def check_password(password, password_hash):
|
||||
"""Check password against hash
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
password_hash: Bcrypt hash to check against
|
||||
|
||||
Returns:
|
||||
bool: True if password matches, False otherwise
|
||||
"""
|
||||
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Feedback storage service"""
|
||||
import os
|
||||
import shutil
|
||||
import yaml
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
from app.models.feedback import Feedback, AnalysisResult
|
||||
from app.utils.file_validator import get_safe_filename
|
||||
|
||||
|
||||
class FeedbackStorageService:
|
||||
"""Service for storing feedback to filesystem"""
|
||||
|
||||
@staticmethod
|
||||
def create_feedback(product_id, content_text=None, files=None):
|
||||
"""Create new feedback entry
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
content_text: Feedback text content (optional)
|
||||
files: List of uploaded files (optional)
|
||||
|
||||
Returns:
|
||||
Feedback: Created feedback instance
|
||||
"""
|
||||
# Generate unique feedback ID
|
||||
feedback_id = Feedback.generate_id()
|
||||
|
||||
# Create content preview (first 200 chars)
|
||||
content_preview = ''
|
||||
if content_text:
|
||||
content_preview = content_text[:200]
|
||||
|
||||
# Check attachments
|
||||
has_attachments = bool(files and len(files) > 0)
|
||||
attachment_count = len(files) if files else 0
|
||||
|
||||
# Create feedback instance
|
||||
feedback = Feedback(
|
||||
feedback_id=feedback_id,
|
||||
product_id=product_id,
|
||||
status='new',
|
||||
content_preview=content_preview,
|
||||
has_attachments=has_attachments,
|
||||
attachment_count=attachment_count
|
||||
)
|
||||
|
||||
# Create directory structure
|
||||
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
||||
os.makedirs(feedback_dir, exist_ok=True)
|
||||
|
||||
return feedback
|
||||
|
||||
@staticmethod
|
||||
def save_metadata(feedback):
|
||||
"""Save feedback metadata to YAML file
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance to save
|
||||
"""
|
||||
feedback.save_metadata()
|
||||
|
||||
@staticmethod
|
||||
def save_content(feedback, content_text):
|
||||
"""Save feedback content to text file
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance
|
||||
content_text: Feedback text content
|
||||
"""
|
||||
if not content_text:
|
||||
return
|
||||
|
||||
content_file = Feedback._get_content_file(feedback.product_id, feedback.feedback_id)
|
||||
|
||||
with open(content_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content_text)
|
||||
|
||||
@staticmethod
|
||||
def save_attachments(feedback, files):
|
||||
"""Save attachment files
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance
|
||||
files: List of Werkzeug FileStorage objects
|
||||
|
||||
Returns:
|
||||
list: List of saved filenames
|
||||
"""
|
||||
if not files:
|
||||
return []
|
||||
|
||||
attachments_dir = Feedback._get_attachments_dir(feedback.product_id, feedback.feedback_id)
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
|
||||
saved_files = []
|
||||
|
||||
for file in files:
|
||||
if not file or file.filename == '':
|
||||
continue
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = get_safe_filename(file.filename)
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(attachments_dir, safe_filename)
|
||||
file.save(file_path)
|
||||
|
||||
saved_files.append(safe_filename)
|
||||
|
||||
return saved_files
|
||||
|
||||
@staticmethod
|
||||
def save_complete_feedback(product_id, content_text=None, files=None):
|
||||
"""Create and save complete feedback submission
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
content_text: Feedback text content (optional)
|
||||
files: List of uploaded files (optional)
|
||||
|
||||
Returns:
|
||||
Feedback: Created and saved feedback instance
|
||||
"""
|
||||
# Create feedback
|
||||
feedback = FeedbackStorageService.create_feedback(product_id, content_text, files)
|
||||
|
||||
# Save content
|
||||
if content_text:
|
||||
FeedbackStorageService.save_content(feedback, content_text)
|
||||
|
||||
# Save attachments
|
||||
if files:
|
||||
FeedbackStorageService.save_attachments(feedback, files)
|
||||
|
||||
# Save metadata
|
||||
FeedbackStorageService.save_metadata(feedback)
|
||||
|
||||
return feedback
|
||||
|
||||
@staticmethod
|
||||
def update_feedback_status(feedback, new_status):
|
||||
"""Update feedback status
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance
|
||||
new_status: New status value
|
||||
|
||||
Returns:
|
||||
bool: True if updated successfully, False otherwise
|
||||
"""
|
||||
if new_status not in Feedback.VALID_STATUSES:
|
||||
return False
|
||||
|
||||
feedback.status = new_status
|
||||
feedback.save_metadata()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def delete_feedback(feedback):
|
||||
"""Delete feedback and all associated files
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance to delete
|
||||
"""
|
||||
feedback_dir = Feedback._get_feedback_dir(feedback.product_id, feedback.feedback_id)
|
||||
|
||||
if os.path.exists(feedback_dir):
|
||||
shutil.rmtree(feedback_dir)
|
||||
|
||||
@staticmethod
|
||||
def load_feedback_list(product_ids=None, page=1, per_page=50, filters=None, search_query=None):
|
||||
"""Load feedback list with filtering, searching, and pagination
|
||||
|
||||
Args:
|
||||
product_ids: List of product IDs to load feedback for (None = all products)
|
||||
page: Page number (1-indexed)
|
||||
per_page: Items per page
|
||||
filters: Dict with filter criteria (category, status, language, date_range)
|
||||
search_query: Search query string
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'items': List of feedback dicts,
|
||||
'total': Total count,
|
||||
'page': Current page,
|
||||
'per_page': Items per page,
|
||||
'pages': Total pages
|
||||
}
|
||||
"""
|
||||
data_dir = current_app.config['DATA_DIR']
|
||||
products_dir = os.path.join(data_dir, 'products')
|
||||
|
||||
all_feedback = []
|
||||
|
||||
# If no product_ids specified, load all products
|
||||
if product_ids is None:
|
||||
product_ids = []
|
||||
if os.path.exists(products_dir):
|
||||
for item in os.listdir(products_dir):
|
||||
if os.path.isdir(os.path.join(products_dir, item)):
|
||||
product_ids.append(item)
|
||||
|
||||
# Load feedback from each product
|
||||
for product_id in product_ids:
|
||||
feedback_dir = os.path.join(products_dir, product_id, 'feedback')
|
||||
|
||||
if not os.path.exists(feedback_dir):
|
||||
continue
|
||||
|
||||
for feedback_id in os.listdir(feedback_dir):
|
||||
feedback_path = os.path.join(feedback_dir, feedback_id)
|
||||
|
||||
if not os.path.isdir(feedback_path):
|
||||
continue
|
||||
|
||||
# Load metadata
|
||||
metadata_file = os.path.join(feedback_path, 'metadata.yaml')
|
||||
if not os.path.exists(metadata_file):
|
||||
continue
|
||||
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
# Load content preview
|
||||
content_file = os.path.join(feedback_path, 'content.txt')
|
||||
content_preview = ''
|
||||
if os.path.exists(content_file):
|
||||
with open(content_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
content_preview = content[:200]
|
||||
|
||||
# Add to list
|
||||
feedback_data = {
|
||||
'feedback_id': feedback_id,
|
||||
'product_id': product_id,
|
||||
'status': metadata.get('status', 'new'),
|
||||
'category': metadata.get('category', 'uncategorized'),
|
||||
'original_language': metadata.get('original_language', 'unknown'),
|
||||
'submitted_at': metadata.get('submitted_at'),
|
||||
'has_attachments': metadata.get('has_attachments', False),
|
||||
'attachment_count': metadata.get('attachment_count', 0),
|
||||
'content_preview': content_preview
|
||||
}
|
||||
|
||||
all_feedback.append(feedback_data)
|
||||
|
||||
# Apply filters
|
||||
if filters:
|
||||
all_feedback = FeedbackStorageService._apply_filters(all_feedback, filters)
|
||||
|
||||
# Apply search
|
||||
if search_query:
|
||||
all_feedback = FeedbackStorageService._apply_search(all_feedback, search_query)
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
all_feedback.sort(key=lambda x: x.get('submitted_at', ''), reverse=True)
|
||||
|
||||
# Calculate pagination
|
||||
total = len(all_feedback)
|
||||
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
|
||||
# Get page items
|
||||
items = all_feedback[start_idx:end_idx]
|
||||
|
||||
return {
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'pages': total_pages
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _apply_filters(feedback_list, filters):
|
||||
"""Apply filters to feedback list
|
||||
|
||||
Args:
|
||||
feedback_list: List of feedback dicts
|
||||
filters: Dict with filter criteria
|
||||
|
||||
Returns:
|
||||
list: Filtered feedback list
|
||||
"""
|
||||
filtered = feedback_list
|
||||
|
||||
# Filter by category
|
||||
if filters.get('category'):
|
||||
filtered = [f for f in filtered if f.get('category') == filters['category']]
|
||||
|
||||
# Filter by status
|
||||
if filters.get('status'):
|
||||
filtered = [f for f in filtered if f.get('status') == filters['status']]
|
||||
|
||||
# Filter by language
|
||||
if filters.get('language'):
|
||||
filtered = [f for f in filtered if f.get('original_language') == filters['language']]
|
||||
|
||||
# Filter by date range
|
||||
if filters.get('date_from') or filters.get('date_to'):
|
||||
date_from = filters.get('date_from')
|
||||
date_to = filters.get('date_to')
|
||||
|
||||
def in_date_range(feedback):
|
||||
submitted_at = feedback.get('submitted_at')
|
||||
if not submitted_at:
|
||||
return False
|
||||
|
||||
if date_from and submitted_at < date_from:
|
||||
return False
|
||||
if date_to and submitted_at > date_to:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
filtered = [f for f in filtered if in_date_range(f)]
|
||||
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def _apply_search(feedback_list, search_query):
|
||||
"""Apply search query to feedback list
|
||||
|
||||
Searches in content preview, category, and status
|
||||
|
||||
Args:
|
||||
feedback_list: List of feedback dicts
|
||||
search_query: Search string
|
||||
|
||||
Returns:
|
||||
list: Filtered feedback list
|
||||
"""
|
||||
if not search_query:
|
||||
return feedback_list
|
||||
|
||||
query_lower = search_query.lower()
|
||||
|
||||
def matches_search(feedback):
|
||||
# Search in content preview
|
||||
if query_lower in feedback.get('content_preview', '').lower():
|
||||
return True
|
||||
|
||||
# Search in category
|
||||
if query_lower in feedback.get('category', '').lower():
|
||||
return True
|
||||
|
||||
# Search in feedback ID
|
||||
if query_lower in feedback.get('feedback_id', '').lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return [f for f in feedback_list if matches_search(f)]
|
||||
|
||||
@staticmethod
|
||||
def load_feedback_detail(product_id, feedback_id):
|
||||
"""Load complete feedback details
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
|
||||
Returns:
|
||||
dict: Complete feedback data or None if not found
|
||||
"""
|
||||
data_dir = current_app.config['DATA_DIR']
|
||||
feedback_path = os.path.join(data_dir, 'products', product_id, 'feedback', feedback_id)
|
||||
|
||||
if not os.path.exists(feedback_path):
|
||||
return None
|
||||
|
||||
# Load metadata
|
||||
metadata_file = os.path.join(feedback_path, 'metadata.yaml')
|
||||
if not os.path.exists(metadata_file):
|
||||
return None
|
||||
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
# Load content
|
||||
content_file = os.path.join(feedback_path, 'content.txt')
|
||||
content = ''
|
||||
if os.path.exists(content_file):
|
||||
with open(content_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Load analysis if exists
|
||||
analysis_file = os.path.join(feedback_path, 'analysis.md')
|
||||
analysis = ''
|
||||
if os.path.exists(analysis_file):
|
||||
with open(analysis_file, 'r', encoding='utf-8') as f:
|
||||
analysis = f.read()
|
||||
|
||||
# List attachments
|
||||
attachments = []
|
||||
attachments_dir = os.path.join(feedback_path, 'attachments')
|
||||
if os.path.exists(attachments_dir):
|
||||
attachments = os.listdir(attachments_dir)
|
||||
|
||||
return {
|
||||
'feedback_id': feedback_id,
|
||||
'product_id': product_id,
|
||||
'metadata': metadata,
|
||||
'content': content,
|
||||
'analysis': analysis,
|
||||
'attachments': attachments
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_feedback_status_by_id(product_id, feedback_id, new_status):
|
||||
"""Update feedback status by IDs
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
new_status: New status value
|
||||
|
||||
Returns:
|
||||
bool: True if updated successfully, False otherwise
|
||||
"""
|
||||
data_dir = current_app.config['DATA_DIR']
|
||||
metadata_file = os.path.join(
|
||||
data_dir, 'products', product_id, 'feedback', feedback_id, 'metadata.yaml'
|
||||
)
|
||||
|
||||
if not os.path.exists(metadata_file):
|
||||
return False
|
||||
|
||||
# Load metadata
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
# Update status
|
||||
if new_status not in Feedback.VALID_STATUSES:
|
||||
return False
|
||||
|
||||
metadata['status'] = new_status
|
||||
|
||||
# Save metadata
|
||||
with open(metadata_file, 'w') as f:
|
||||
yaml.dump(metadata, f)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_attachment_path(product_id, feedback_id, filename):
|
||||
"""Get path to attachment file
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
filename: Attachment filename
|
||||
|
||||
Returns:
|
||||
str: Full path to attachment file or None if not found
|
||||
"""
|
||||
data_dir = current_app.config['DATA_DIR']
|
||||
attachment_path = os.path.join(
|
||||
data_dir, 'products', product_id, 'feedback', feedback_id, 'attachments', filename
|
||||
)
|
||||
|
||||
if not os.path.exists(attachment_path):
|
||||
return None
|
||||
|
||||
# Check for path traversal
|
||||
attachments_dir = os.path.join(data_dir, 'products', product_id, 'feedback', feedback_id, 'attachments')
|
||||
if not os.path.abspath(attachment_path).startswith(os.path.abspath(attachments_dir)):
|
||||
return None
|
||||
|
||||
return attachment_path
|
||||
|
||||
@staticmethod
|
||||
def save_analysis(product_id, feedback_id, analysis_result):
|
||||
"""Save AI analysis results to filesystem (T082)
|
||||
|
||||
Creates analysis.md file with formatted analysis results and updates
|
||||
metadata with category and language information.
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
analysis_result: AnalysisResult instance with analysis data
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully, False otherwise
|
||||
"""
|
||||
data_dir = current_app.config['DATA_DIR']
|
||||
feedback_dir = os.path.join(data_dir, 'products', product_id, 'feedback', feedback_id)
|
||||
|
||||
if not os.path.exists(feedback_dir):
|
||||
return False
|
||||
|
||||
# Create analysis.md file with formatted content (T083)
|
||||
analysis_file = os.path.join(feedback_dir, 'analysis.md')
|
||||
analysis_markdown = FeedbackStorageService._create_analysis_markdown(analysis_result)
|
||||
|
||||
with open(analysis_file, 'w', encoding='utf-8') as f:
|
||||
f.write(analysis_markdown)
|
||||
|
||||
# Update metadata with category and language (T089)
|
||||
metadata_file = os.path.join(feedback_dir, 'metadata.yaml')
|
||||
|
||||
if os.path.exists(metadata_file):
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
# Store detected language
|
||||
metadata['original_language'] = analysis_result.original_language
|
||||
# Store category
|
||||
metadata['category'] = analysis_result.category
|
||||
|
||||
with open(metadata_file, 'w') as f:
|
||||
yaml.dump(metadata, f)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _create_analysis_markdown(analysis_result):
|
||||
"""Create formatted analysis markdown (T083)
|
||||
|
||||
Args:
|
||||
analysis_result: AnalysisResult instance
|
||||
|
||||
Returns:
|
||||
str: Formatted markdown content
|
||||
"""
|
||||
# Use the raw analysis from Claude, which is already formatted
|
||||
return analysis_result.raw_analysis
|
||||
|
||||
@staticmethod
|
||||
def has_analysis(product_id, feedback_id):
|
||||
"""Check if feedback has been analyzed
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
feedback_id: Feedback ID
|
||||
|
||||
Returns:
|
||||
bool: True if analysis.md exists, False otherwise
|
||||
"""
|
||||
data_dir = current_app.config['DATA_DIR']
|
||||
analysis_file = os.path.join(
|
||||
data_dir, 'products', product_id, 'feedback', feedback_id, 'analysis.md'
|
||||
)
|
||||
return os.path.exists(analysis_file)
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Reklamator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Login</h1>
|
||||
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
<p style="margin-top: 20px;">
|
||||
<a href="{{ url_for('index') }}">Return to home page</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Reklamator - Anonymous Feedback{% endblock %}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1, h2, h3 {
|
||||
margin-bottom: 20px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 3px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.flash-messages {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.flash {
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
.flash.success {
|
||||
background-color: #d4edda;
|
||||
border-color: #28a745;
|
||||
color: #155724;
|
||||
}
|
||||
.flash.error {
|
||||
background-color: #f8d7da;
|
||||
border-color: #dc3545;
|
||||
color: #721c24;
|
||||
}
|
||||
.flash.info {
|
||||
background-color: #d1ecf1;
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
.flash.warning {
|
||||
background-color: #fff3cd;
|
||||
border-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
form {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1em;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
}
|
||||
button,
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
button:hover,
|
||||
.btn:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
button.secondary,
|
||||
.btn.secondary {
|
||||
background-color: #95a5a6;
|
||||
}
|
||||
button.secondary:hover,
|
||||
.btn.secondary:hover {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
button.danger,
|
||||
.btn.danger {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
button.danger:hover,
|
||||
.btn.danger:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.new {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
.badge.analyzed {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
.badge.archived {
|
||||
background-color: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{% if current_user and current_user.is_authenticated %}
|
||||
<div class="nav">
|
||||
<a href="{{ url_for('index') }}">Home</a>
|
||||
{% if current_user.role == 'product_owner' %}
|
||||
<span style="color: #999;">(Dashboard - Coming in Phase 5)</span>
|
||||
{% elif current_user.role == 'administrator' %}
|
||||
<span style="color: #999;">(Admin Panel - Coming in Phase 6)</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}" style="float: right;">Logout ({{ current_user.username }})</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Feedback Detail{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 900px; margin: 0 auto; padding: 20px;">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<a href="{{ url_for('dashboard.list') }}" style="color: #007bff; text-decoration: none;">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<h1>Feedback Detail</h1>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;">
|
||||
<div>
|
||||
<strong>Feedback ID:</strong>
|
||||
<p style="margin: 5px 0; font-family: monospace; font-size: 0.9em;">{{ feedback.feedback_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Product:</strong>
|
||||
<p style="margin: 5px 0;">{{ product.name if product else feedback.product_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>
|
||||
<p style="margin: 5px 0;">
|
||||
<span style="padding: 5px 10px; border-radius: 3px;
|
||||
{% if feedback.metadata.status == 'new' %}background: #d1ecf1; color: #0c5460;
|
||||
{% elif feedback.metadata.status == 'in_progress' %}background: #fff3cd; color: #856404;
|
||||
{% elif feedback.metadata.status == 'resolved' %}background: #d4edda; color: #155724;
|
||||
{% else %}background: #e2e3e5; color: #383d41;{% endif %}">
|
||||
{{ feedback.metadata.status }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Category:</strong>
|
||||
<p style="margin: 5px 0;">
|
||||
<span style="padding: 5px 10px; background: #e9ecef; border-radius: 3px;">
|
||||
{{ feedback.metadata.category or 'Uncategorized' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Language:</strong>
|
||||
<p style="margin: 5px 0;">{{ feedback.metadata.original_language or 'Unknown' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Submitted:</strong>
|
||||
<p style="margin: 5px 0;">{{ feedback.metadata.submitted_at[:19] if feedback.metadata.submitted_at else 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Status Form -->
|
||||
<div style="margin: 20px 0; padding: 15px; background: #fff3cd; border-radius: 5px;">
|
||||
<form method="post" action="{{ url_for('dashboard.update_status', feedback_id=feedback.feedback_id) }}" style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="status"><strong>Update Status:</strong></label>
|
||||
<select name="status" id="status" style="padding: 5px 10px; border: 1px solid #ccc; border-radius: 3px;">
|
||||
<option value="new" {% if feedback.metadata.status == 'new' %}selected{% endif %}>New</option>
|
||||
<option value="in_progress" {% if feedback.metadata.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="resolved" {% if feedback.metadata.status == 'resolved' %}selected{% endif %}>Resolved</option>
|
||||
<option value="closed" {% if feedback.metadata.status == 'closed' %}selected{% endif %}>Closed</option>
|
||||
</select>
|
||||
<button type="submit" style="padding: 5px 15px; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer;">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis Trigger -->
|
||||
{% if can_analyze %}
|
||||
<div style="margin: 20px 0; padding: 15px; {% if has_analysis %}background: #d1ecf1;{% else %}background: #d4edda;{% endif %} border-radius: 5px;">
|
||||
<form method="post" action="{{ url_for('dashboard.trigger_analysis', feedback_id=feedback.feedback_id) }}" style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if has_analysis %}
|
||||
<span style="font-size: 1.2em;">🔄</span>
|
||||
<label><strong>Re-run AI Analysis:</strong> This feedback has been analyzed. Click to re-analyze.</label>
|
||||
{% else %}
|
||||
<span style="font-size: 1.2em;">🤖</span>
|
||||
<label><strong>Run AI Analysis:</strong> This feedback has not been analyzed yet.</label>
|
||||
{% endif %}
|
||||
<button type="submit" style="padding: 5px 15px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">
|
||||
{% if has_analysis %}Re-analyze{% else %}Analyze{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% elif not feedback.content %}
|
||||
<div style="margin: 20px 0; padding: 15px; background: #f8d7da; border-radius: 5px; color: #721c24;">
|
||||
<span style="font-size: 1.2em;">ℹ️</span>
|
||||
<strong>Cannot analyze:</strong> This feedback has no text content (attachments only).
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Original Content -->
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>Original Feedback</h2>
|
||||
<div style="background: white; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; white-space: pre-wrap;">{{ feedback.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Analysis (if exists) -->
|
||||
{% if feedback.analysis %}
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>AI Analysis</h2>
|
||||
<div style="background: white; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px;">
|
||||
{{ feedback.analysis|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Attachments -->
|
||||
{% if feedback.attachments %}
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>Attachments ({{ feedback.attachments|length }})</h2>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
{% for attachment in feedback.attachments %}
|
||||
<li style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 3px;">
|
||||
<a href="{{ url_for('dashboard.download_attachment', feedback_id=feedback.feedback_id, filename=attachment) }}"
|
||||
style="color: #007bff; text-decoration: none; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2em;">📎</span>
|
||||
<span>{{ attachment }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>Attachments</h2>
|
||||
<p style="color: #666;">No attachments</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Feedback Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 1200px; margin: 0 auto; padding: 20px;">
|
||||
<h1>Feedback Dashboard</h1>
|
||||
|
||||
<!-- Filters and Search -->
|
||||
<form method="get" action="{{ url_for('dashboard.list') }}" style="margin: 20px 0; padding: 15px; background: #f5f5f5; border-radius: 5px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px;">
|
||||
<div>
|
||||
<label for="category">Category:</label>
|
||||
<select name="category" id="category">
|
||||
<option value="">All Categories</option>
|
||||
<option value="bug" {% if filters.category == 'bug' %}selected{% endif %}>Bug</option>
|
||||
<option value="feature_request" {% if filters.category == 'feature_request' %}selected{% endif %}>Feature Request</option>
|
||||
<option value="question" {% if filters.category == 'question' %}selected{% endif %}>Question</option>
|
||||
<option value="complaint" {% if filters.category == 'complaint' %}selected{% endif %}>Complaint</option>
|
||||
<option value="praise" {% if filters.category == 'praise' %}selected{% endif %}>Praise</option>
|
||||
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status">Status:</label>
|
||||
<select name="status" id="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="new" {% if filters.status == 'new' %}selected{% endif %}>New</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>Resolved</option>
|
||||
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="search">Search:</label>
|
||||
<input type="text" name="search" id="search" value="{{ filters.search or '' }}" placeholder="Search feedback...">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-end; gap: 5px;">
|
||||
<button type="submit" style="padding: 8px 15px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Filter</button>
|
||||
<a href="{{ url_for('dashboard.list') }}" style="padding: 8px 15px; background: #6c757d; color: white; text-decoration: none; border-radius: 3px; display: inline-block;">Clear</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<p style="margin: 10px 0; color: #666;">
|
||||
Showing {{ feedback_list|length }} of {{ total }} feedback items
|
||||
{% if filters.category or filters.status or filters.search %}
|
||||
(filtered)
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<!-- Feedback List -->
|
||||
{% if feedback_list %}
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
|
||||
<th style="padding: 12px; text-align: left;">ID</th>
|
||||
<th style="padding: 12px; text-align: left;">Product</th>
|
||||
<th style="padding: 12px; text-align: left;">Preview</th>
|
||||
<th style="padding: 12px; text-align: left;">Category</th>
|
||||
<th style="padding: 12px; text-align: left;">Status</th>
|
||||
<th style="padding: 12px; text-align: left;">Date</th>
|
||||
<th style="padding: 12px; text-align: left;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for feedback in feedback_list %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 12px; font-family: monospace; font-size: 0.9em;">
|
||||
{{ feedback.feedback_id[:8] }}...
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{{ product_names.get(feedback.product_id, feedback.product_id) }}
|
||||
</td>
|
||||
<td style="padding: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ feedback.content_preview }}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<span style="padding: 3px 8px; background: #e9ecef; border-radius: 3px; font-size: 0.9em;">
|
||||
{{ feedback.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<span style="padding: 3px 8px; border-radius: 3px; font-size: 0.9em;
|
||||
{% if feedback.status == 'new' %}background: #d1ecf1; color: #0c5460;
|
||||
{% elif feedback.status == 'in_progress' %}background: #fff3cd; color: #856404;
|
||||
{% elif feedback.status == 'resolved' %}background: #d4edda; color: #155724;
|
||||
{% else %}background: #e2e3e5; color: #383d41;{% endif %}">
|
||||
{{ feedback.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px; font-size: 0.9em; color: #666;">
|
||||
{{ feedback.submitted_at[:10] if feedback.submitted_at else 'Unknown' }}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<a href="{{ url_for('dashboard.detail', feedback_id=feedback.feedback_id) }}"
|
||||
style="color: #007bff; text-decoration: none;">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pages > 1 %}
|
||||
<div style="margin: 20px 0; text-align: center;">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('dashboard.list', page=page-1, category=filters.category, status=filters.status, search=filters.search) }}"
|
||||
style="padding: 8px 12px; margin: 0 2px; background: #007bff; color: white; text-decoration: none; border-radius: 3px;">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span style="padding: 8px 12px; margin: 0 5px;">Page {{ page }} of {{ pages }}</span>
|
||||
|
||||
{% if page < pages %}
|
||||
<a href="{{ url_for('dashboard.list', page=page+1, category=filters.category, status=filters.status, search=filters.search) }}"
|
||||
style="padding: 8px 12px; margin: 0 2px; background: #007bff; color: white; text-decoration: none; border-radius: 3px;">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p style="margin: 40px 0; text-align: center; color: #666;">
|
||||
No feedback found.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Access Denied{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 600px; margin: 100px auto; text-align: center;">
|
||||
<h1>403 - Access Denied</h1>
|
||||
<p>You do not have permission to access this resource.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('dashboard.list') }}">Return to Dashboard</a> |
|
||||
<a href="{{ url_for('index') }}">Go to Home</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 600px; margin: 100px auto; text-align: center;">
|
||||
<h1>404 - Not Found</h1>
|
||||
<p>The page or resource you requested could not be found.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('dashboard.list') }}">Return to Dashboard</a> |
|
||||
<a href="{{ url_for('index') }}">Go to Home</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Welcome - Reklamator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<h1 style="font-size: 2.5em; margin-bottom: 20px;">Reklamator</h1>
|
||||
<p style="font-size: 1.3em; color: #666; margin-bottom: 40px;">
|
||||
Anonymous Feedback Platform
|
||||
</p>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto; text-align: left;">
|
||||
<h2>Submit Feedback</h2>
|
||||
<p>If you have a product-specific submission link, use it to submit your feedback anonymously.</p>
|
||||
|
||||
<h2 style="margin-top: 40px;">Product Owners & Administrators</h2>
|
||||
<p>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn">Login to Dashboard</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Error - {{ product.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 4em; color: #e74c3c; margin-bottom: 20px;">✗</div>
|
||||
|
||||
<h1>Oops! Something went wrong</h1>
|
||||
|
||||
<p style="font-size: 1.1em; margin: 20px 0; color: #555;">
|
||||
{{ error_message }}
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 40px;">
|
||||
<a href="{{ url_for('submission.form', product_slug=product.submission_url_slug) }}" class="btn">
|
||||
Try Again
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Submit Feedback - {{ product.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Submit Feedback</h1>
|
||||
<h2>{{ product.name }}</h2>
|
||||
|
||||
<p>We value your feedback. Please share your thoughts, report issues, or suggest improvements below.</p>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="feedback_text">Your Feedback</label>
|
||||
<textarea id="feedback_text" name="feedback_text"
|
||||
placeholder="Describe your feedback in any language..."></textarea>
|
||||
<p style="font-size: 0.9em; color: #666; margin-top: 5px;">
|
||||
You can write in any language. Optional if you attach files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="files">Attachments (Optional)</label>
|
||||
<input type="file" id="files" name="files" multiple>
|
||||
<p style="font-size: 0.9em; color: #666; margin-top: 5px;">
|
||||
You can attach up to 3 files (max 10MB each). Allowed types: images (PNG, JPG, GIF),
|
||||
documents (PDF, TXT, DOC, DOCX), spreadsheets (XLS, XLSX, CSV).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0; font-size: 1.1em;">Privacy Notice</h3>
|
||||
<ul style="margin: 10px 0; padding-left: 20px; line-height: 1.8;">
|
||||
<li>Your feedback is submitted anonymously</li>
|
||||
<li>We do not collect or store your IP address</li>
|
||||
<li>All files are scanned for malware</li>
|
||||
<li>Please do not include personal information unless necessary</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button type="submit">Submit Feedback</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Feedback Submitted - {{ product.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 4em; color: #2ecc71; margin-bottom: 20px;">✓</div>
|
||||
|
||||
<h1>Thank You!</h1>
|
||||
|
||||
<p style="font-size: 1.2em; margin: 20px 0;">
|
||||
Your feedback has been successfully submitted.
|
||||
</p>
|
||||
|
||||
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 4px; margin: 30px 0; text-align: left;">
|
||||
<h3 style="margin-top: 0;">What happens next?</h3>
|
||||
<ul style="line-height: 2;">
|
||||
<li>Your feedback will be analyzed automatically</li>
|
||||
<li>The product team will review your submission</li>
|
||||
<li>They may use your feedback to improve {{ product.name }}</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin: 20px 0 10px 0; font-size: 0.9em; color: #666;">
|
||||
<strong>Reference ID:</strong> {{ feedback_id }}
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 0.9em; color: #666;">
|
||||
(This ID is for your reference only. We cannot track individual submissions.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 40px;">
|
||||
<a href="{{ url_for('submission.form', product_slug=product.submission_url_slug) }}" class="btn">
|
||||
Submit More Feedback
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Utilities package"""
|
||||
# Utility functions for validation, security, etc.
|
||||
@@ -0,0 +1,144 @@
|
||||
"""File upload validation utilities"""
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
import clamd
|
||||
from flask import current_app
|
||||
|
||||
|
||||
# Allowed file extensions for attachments
|
||||
ALLOWED_EXTENSIONS = {
|
||||
'txt', 'log', 'pdf', 'png', 'jpg', 'jpeg', 'gif',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'csv'
|
||||
}
|
||||
|
||||
# Maximum file size (10MB)
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
"""Check if file extension is allowed
|
||||
|
||||
Args:
|
||||
filename: Name of the uploaded file
|
||||
|
||||
Returns:
|
||||
bool: True if extension is allowed, False otherwise
|
||||
"""
|
||||
if not filename:
|
||||
return False
|
||||
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def validate_file_size(file_stream):
|
||||
"""Check if file size is within limits
|
||||
|
||||
Args:
|
||||
file_stream: File stream object
|
||||
|
||||
Returns:
|
||||
bool: True if size is acceptable, False otherwise
|
||||
"""
|
||||
# Seek to end to get file size
|
||||
file_stream.seek(0, os.SEEK_END)
|
||||
size = file_stream.tell()
|
||||
# Reset to beginning
|
||||
file_stream.seek(0)
|
||||
|
||||
return size <= MAX_FILE_SIZE
|
||||
|
||||
|
||||
def get_safe_filename(filename):
|
||||
"""Get secure version of filename
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
str: Secure filename safe for filesystem storage
|
||||
"""
|
||||
return secure_filename(filename)
|
||||
|
||||
|
||||
def validate_file(file):
|
||||
"""Validate uploaded file
|
||||
|
||||
Args:
|
||||
file: Werkzeug FileStorage object
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, error_message)
|
||||
is_valid: bool indicating if file is valid
|
||||
error_message: str with error description or None
|
||||
"""
|
||||
if not file:
|
||||
return False, "No file provided"
|
||||
|
||||
if file.filename == '':
|
||||
return False, "No file selected"
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return False, f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}"
|
||||
|
||||
if not validate_file_size(file.stream):
|
||||
return False, f"File size exceeds maximum of {MAX_FILE_SIZE / (1024 * 1024):.0f}MB"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def scan_file_for_viruses(file):
|
||||
"""Scan file for viruses using ClamAV
|
||||
|
||||
Args:
|
||||
file: Werkzeug FileStorage object
|
||||
|
||||
Returns:
|
||||
tuple: (is_clean, error_message)
|
||||
is_clean: bool indicating if file is clean (True) or infected (False)
|
||||
error_message: str with error description or None
|
||||
"""
|
||||
try:
|
||||
# Connect to ClamAV daemon
|
||||
clamd_socket = current_app.config.get('CLAMD_SOCKET')
|
||||
|
||||
if not clamd_socket:
|
||||
# ClamAV not configured, skip scanning
|
||||
current_app.logger.warning("ClamAV socket not configured, skipping virus scan")
|
||||
return True, None
|
||||
|
||||
cd = clamd.ClamdUnixSocket(clamd_socket)
|
||||
|
||||
# Ping to check if ClamAV is available
|
||||
try:
|
||||
cd.ping()
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"ClamAV not available: {e}, skipping virus scan")
|
||||
return True, None
|
||||
|
||||
# Read file content
|
||||
file.stream.seek(0)
|
||||
file_data = file.stream.read()
|
||||
file.stream.seek(0) # Reset for later use
|
||||
|
||||
# Scan file
|
||||
scan_result = cd.instream(file_data)
|
||||
|
||||
# Check result
|
||||
if scan_result and 'stream' in scan_result:
|
||||
status, virus_name = scan_result['stream']
|
||||
|
||||
if status == 'OK':
|
||||
return True, None
|
||||
elif status == 'FOUND':
|
||||
return False, f"Virus detected: {virus_name}"
|
||||
else:
|
||||
return False, f"Scan error: {status}"
|
||||
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"ClamAV scanning error: {e}")
|
||||
# On error, we'll allow the file but log the error
|
||||
# In production, you might want to reject files if scanning fails
|
||||
return True, None
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Development configuration"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file for development
|
||||
load_dotenv()
|
||||
|
||||
class DevelopmentConfig:
|
||||
"""Development environment configuration"""
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
# Security
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
|
||||
# Paths
|
||||
DATA_DIR = os.environ.get('DATA_DIR', './data')
|
||||
|
||||
# Flask-WTF CSRF
|
||||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_TIME_LIMIT = None
|
||||
|
||||
# File Upload
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 10 * 1024 * 1024)) # 10MB
|
||||
|
||||
# AI Integration
|
||||
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY')
|
||||
|
||||
# ClamAV
|
||||
CLAMD_SOCKET = os.environ.get('CLAMD_SOCKET', '/var/run/clamav/clamd.ctl')
|
||||
|
||||
# Rate Limiting
|
||||
RATELIMIT_ENABLED = os.environ.get('RATE_LIMIT_ENABLED', 'true').lower() == 'true'
|
||||
RATELIMIT_STORAGE_URL = 'memory://'
|
||||
RATELIMIT_PER_HOUR = int(os.environ.get('RATE_LIMIT_PER_HOUR', 10))
|
||||
|
||||
# Session
|
||||
SESSION_COOKIE_SECURE = False # Allow HTTP in development
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
PERMANENT_SESSION_LIFETIME = 86400 # 24 hours
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Production configuration"""
|
||||
import os
|
||||
|
||||
class ProductionConfig:
|
||||
"""Production environment configuration"""
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
# Security
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') # Required in production
|
||||
if not SECRET_KEY:
|
||||
raise ValueError("SECRET_KEY environment variable must be set in production")
|
||||
|
||||
# Paths
|
||||
DATA_DIR = os.environ.get('DATA_DIR', '/var/lib/reklamator/data')
|
||||
|
||||
# Flask-WTF CSRF
|
||||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_TIME_LIMIT = None
|
||||
|
||||
# File Upload
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 10 * 1024 * 1024)) # 10MB
|
||||
|
||||
# AI Integration
|
||||
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY') # Required
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise ValueError("ANTHROPIC_API_KEY environment variable must be set in production")
|
||||
|
||||
# ClamAV
|
||||
CLAMD_SOCKET = os.environ.get('CLAMD_SOCKET', '/var/run/clamav/clamd.ctl')
|
||||
|
||||
# Rate Limiting
|
||||
RATELIMIT_ENABLED = os.environ.get('RATE_LIMIT_ENABLED', 'true').lower() == 'true'
|
||||
RATELIMIT_STORAGE_URL = 'memory://'
|
||||
RATELIMIT_PER_HOUR = int(os.environ.get('RATE_LIMIT_PER_HOUR', 10))
|
||||
|
||||
# Session - HTTPS only
|
||||
SESSION_COOKIE_SECURE = True # HTTPS only
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
PERMANENT_SESSION_LIFETIME = 86400 # 24 hours
|
||||
|
||||
# Security Headers (T196 - HSTS for HTTPS enforcement)
|
||||
SEND_FILE_MAX_AGE_DEFAULT = 31536000 # 1 year for static files
|
||||
STRICT_TRANSPORT_SECURITY = 'max-age=31536000; includeSubDomains' # HSTS: 1 year
|
||||
X_CONTENT_TYPE_OPTIONS = 'nosniff'
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
X_XSS_PROTECTION = '1; mode=block'
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Testing configuration"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
class TestingConfig:
|
||||
"""Testing environment configuration"""
|
||||
DEBUG = False
|
||||
TESTING = True
|
||||
|
||||
# Security
|
||||
SECRET_KEY = 'test-secret-key'
|
||||
|
||||
# Paths - use temporary directory
|
||||
DATA_DIR = tempfile.mkdtemp()
|
||||
|
||||
# Flask-WTF CSRF - disabled for easier testing
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
# File Upload
|
||||
MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
# AI Integration - mock in tests
|
||||
ANTHROPIC_API_KEY = 'test-api-key'
|
||||
|
||||
# ClamAV - mock in tests
|
||||
CLAMD_SOCKET = '/tmp/test-clamd.ctl'
|
||||
|
||||
# Rate Limiting - disabled for testing
|
||||
RATELIMIT_ENABLED = False
|
||||
RATELIMIT_STORAGE_URL = 'memory://'
|
||||
RATELIMIT_PER_HOUR = 1000 # High limit for testing
|
||||
|
||||
# Session
|
||||
SESSION_COOKIE_SECURE = False
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
PERMANENT_SESSION_LIFETIME = 86400
|
||||
@@ -0,0 +1,275 @@
|
||||
# Technical Debt
|
||||
|
||||
This document tracks known technical debt in the Reklamator project.
|
||||
|
||||
## Definition
|
||||
|
||||
Technical debt refers to:
|
||||
- Missing test coverage
|
||||
- Known limitations or workarounds
|
||||
- Deferred improvements
|
||||
- Areas needing refactoring
|
||||
|
||||
---
|
||||
|
||||
## Current Technical Debt
|
||||
|
||||
### 1. Missing Authentication Route Tests
|
||||
|
||||
**Severity**: Medium
|
||||
**Phase Introduced**: Phase 3 (MVP)
|
||||
**Status**: Open
|
||||
|
||||
**Description**:
|
||||
Authentication routes (login/logout) lack comprehensive test coverage. The routes work but were not covered by contract tests during initial implementation.
|
||||
|
||||
**Missing Tests**:
|
||||
- Contract tests for `/auth/login` (GET)
|
||||
- Contract tests for `/auth/login` (POST) with valid credentials
|
||||
- Contract tests for `/auth/login` (POST) with invalid credentials
|
||||
- Contract tests for `/auth/logout`
|
||||
- Integration tests for complete login/logout flow
|
||||
- Tests for session management
|
||||
- Tests for authenticated vs unauthenticated access
|
||||
|
||||
**Impact**:
|
||||
- Authentication bugs may go undetected until manual testing
|
||||
- Risk of regression when modifying auth code
|
||||
|
||||
**Mitigation**:
|
||||
- User model has comprehensive unit tests (15 tests) added after MVP
|
||||
- Manual testing verified login/logout functionality
|
||||
- CSRF protection tested manually (disabled in test config by design)
|
||||
|
||||
**Plan to Resolve**:
|
||||
- Add authentication contract tests in Phase 5 when implementing dashboard (which requires authentication)
|
||||
- Or address as standalone task before Phase 4
|
||||
|
||||
---
|
||||
|
||||
### 2. CSRF Testing Disabled in Test Environment
|
||||
|
||||
**Severity**: Low
|
||||
**Phase Introduced**: Phase 2 (Foundational)
|
||||
**Status**: Accepted (By Design)
|
||||
|
||||
**Description**:
|
||||
CSRF protection is disabled in test configuration (`config/testing.py:17` - `WTF_CSRF_ENABLED = False`). This is a common testing practice but means CSRF bugs only appear in development/production.
|
||||
|
||||
**Impact**:
|
||||
- CSRF-related bugs require manual testing to catch
|
||||
- Forms without CSRF tokens will pass tests but fail in dev/prod
|
||||
|
||||
**Bugs Found**:
|
||||
- Bug #1: Missing CSRF token in submission form (found manually)
|
||||
- Bug #2: Missing CSRF token in login form (found manually)
|
||||
|
||||
**Mitigation**:
|
||||
- Both forms now include CSRF tokens
|
||||
- Manual testing checklist includes form submission
|
||||
- CSRF protection verified working in development environment
|
||||
|
||||
**Plan to Resolve**:
|
||||
- Consider adding integration tests with CSRF enabled
|
||||
- Or document as accepted trade-off for simpler testing
|
||||
|
||||
---
|
||||
|
||||
### 3. Dashboard Routes Not Implemented
|
||||
|
||||
**Severity**: Low (Expected)
|
||||
**Phase Introduced**: Phase 3 (MVP)
|
||||
**Status**: Planned
|
||||
|
||||
**Description**:
|
||||
Login/logout routes reference dashboard endpoints that don't exist yet:
|
||||
- `admin.dashboard` (Phase 6 - User Story 4)
|
||||
- `dashboard.list` (Phase 5 - User Story 3)
|
||||
|
||||
**Current Workaround**:
|
||||
- All users redirect to index page after login
|
||||
- Base template shows "Coming in Phase X" messages
|
||||
- TODO comments in code mark areas for future implementation
|
||||
|
||||
**Impact**:
|
||||
- Users cannot access dashboards after login (expected for MVP)
|
||||
- Navigation shows placeholder text instead of functional links
|
||||
|
||||
**Plan to Resolve**:
|
||||
- Implement in Phase 5 (Product Owner Dashboard)
|
||||
- Implement in Phase 6 (Admin Dashboard)
|
||||
|
||||
---
|
||||
|
||||
### 4. ClamAV Integration Not Fully Tested
|
||||
|
||||
**Severity**: Low
|
||||
**Phase Introduced**: Phase 3 (MVP)
|
||||
**Status**: Open
|
||||
|
||||
**Description**:
|
||||
ClamAV virus scanning has graceful degradation but limited test coverage. Tests run with ClamAV unavailable (skips scanning).
|
||||
|
||||
**Missing Tests**:
|
||||
- Tests with actual ClamAV daemon running
|
||||
- Tests for virus detection
|
||||
- Tests for ClamAV connection failures
|
||||
- Tests for scanning timeout
|
||||
|
||||
**Impact**:
|
||||
- ClamAV integration relies on manual testing
|
||||
- Virus scanning behavior not verified in automated tests
|
||||
|
||||
**Mitigation**:
|
||||
- Code includes comprehensive error handling
|
||||
- Logs warnings when ClamAV unavailable
|
||||
- Falls back gracefully (allows upload, logs warning)
|
||||
|
||||
**Plan to Resolve**:
|
||||
- Add mock ClamAV tests using `unittest.mock`
|
||||
- Or add optional integration tests requiring ClamAV installation
|
||||
- Document ClamAV setup in deployment guide
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes Without Tests
|
||||
|
||||
All bugs found during manual testing should have regression tests added. Track them here:
|
||||
|
||||
### Bug #1: Missing CSRF Token in Forms
|
||||
|
||||
**Date Found**: 2025-10-16
|
||||
**Severity**: High
|
||||
**Found By**: Manual testing
|
||||
**Fixed In**: Commit b8d0d6d
|
||||
|
||||
**Description**:
|
||||
Submission and login forms were missing CSRF token fields, causing "Bad Request - The CSRF token is missing" errors.
|
||||
|
||||
**Root Cause**:
|
||||
- Forms created without `{{ csrf_token() }}` hidden input
|
||||
- CSRF disabled in test config meant tests didn't catch it
|
||||
|
||||
**Test Coverage**:
|
||||
- ❌ No test added (CSRF disabled in test config by design)
|
||||
- ✅ Manual testing verified fix
|
||||
|
||||
**Lesson Learned**:
|
||||
- Always test forms in development environment
|
||||
- Consider manual testing checklist for CSRF-protected forms
|
||||
|
||||
---
|
||||
|
||||
### Bug #2: User Model is_active AttributeError
|
||||
|
||||
**Date Found**: 2025-10-16
|
||||
**Severity**: High
|
||||
**Found By**: Manual testing (login attempt)
|
||||
**Fixed In**: Commit 73a9a74
|
||||
|
||||
**Description**:
|
||||
`AttributeError: can't set attribute 'is_active'` when loading users. Flask-Login's `UserMixin` provides `is_active` as read-only property, conflicting with instance attribute assignment.
|
||||
|
||||
**Root Cause**:
|
||||
- Direct attribute assignment conflicted with Flask-Login property
|
||||
- No unit tests for User model during Phase 2/3
|
||||
|
||||
**Test Coverage**:
|
||||
- ✅ Added 15 comprehensive unit tests in `tests/unit/test_user_model.py`
|
||||
- ✅ Specifically tests `is_active` property (test_user_is_active_property)
|
||||
- ✅ Tests Flask-Login integration (test_user_flask_login_properties)
|
||||
|
||||
**Lesson Learned**:
|
||||
- Test-First Discipline should apply to ALL models, not just user-facing features
|
||||
- Flask-Login integration needs explicit testing
|
||||
|
||||
---
|
||||
|
||||
### Bug #3: BuildError for Non-Existent Dashboard Routes
|
||||
|
||||
**Date Found**: 2025-10-16
|
||||
**Severity**: Medium
|
||||
**Found By**: Manual testing (successful login)
|
||||
**Fixed In**: Commit d5fd7a7
|
||||
|
||||
**Description**:
|
||||
`werkzeug.routing.exceptions.BuildError: Could not build url for endpoint 'admin.dashboard'` after successful login. Auth routes tried to redirect to unimplemented dashboard routes.
|
||||
|
||||
**Root Cause**:
|
||||
- Forward references to routes not yet implemented (Phase 5/6)
|
||||
- No integration tests for login flow
|
||||
|
||||
**Test Coverage**:
|
||||
- ❌ No test added (dashboards not implemented yet)
|
||||
- ✅ Manual testing verified fix
|
||||
- 📝 TODO comments added for future implementation
|
||||
|
||||
**Lesson Learned**:
|
||||
- Avoid forward references to unimplemented routes
|
||||
- Or use defensive checks (e.g., `url_for()` with try/except)
|
||||
- Integration tests should verify redirect destinations
|
||||
|
||||
---
|
||||
|
||||
## Resolution Priorities
|
||||
|
||||
1. **High Priority**: Add authentication route tests (Phase 5)
|
||||
2. **Medium Priority**: Add ClamAV mock tests
|
||||
3. **Low Priority**: Consider CSRF-enabled integration tests
|
||||
4. **Ongoing**: Add regression test for each bug fix
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
### Current Coverage (Phase 3 - MVP)
|
||||
|
||||
- **Contract Tests**: 8 tests (submission routes)
|
||||
- **Integration Tests**: 2 tests (feedback submission)
|
||||
- **Unit Tests**: 15 tests (User model)
|
||||
- **Total**: 25 tests
|
||||
|
||||
**Coverage by Component**:
|
||||
- ✅ Submission routes: Excellent (8 contract + 2 integration tests)
|
||||
- ✅ User model: Excellent (15 unit tests)
|
||||
- ✅ Feedback model: Good (tested via integration tests)
|
||||
- ✅ Product model: Good (tested via integration tests)
|
||||
- ⚠️ Authentication routes: Poor (0 tests)
|
||||
- ⚠️ File validation: Partial (tested via submission tests)
|
||||
- ❌ Admin routes: None (not implemented)
|
||||
- ❌ Dashboard routes: None (not implemented)
|
||||
|
||||
### Target Coverage (End of MVP+)
|
||||
|
||||
- All user-facing routes: Contract tests
|
||||
- All models: Unit tests
|
||||
- All services: Unit tests
|
||||
- Critical flows: Integration tests
|
||||
- **Minimum**: 80% code coverage
|
||||
|
||||
---
|
||||
|
||||
## How to Add Tests for Bug Fixes
|
||||
|
||||
When fixing a bug:
|
||||
|
||||
1. **Write a failing test** that reproduces the bug
|
||||
2. **Verify the test fails** with the buggy code
|
||||
3. **Fix the bug**
|
||||
4. **Verify the test passes** with the fixed code
|
||||
5. **Document** the bug and test in this file
|
||||
6. **Commit** test and fix together
|
||||
|
||||
See: `.specify/memory/constitution.md` - Bug Fix Protocol
|
||||
|
||||
---
|
||||
|
||||
## Review Schedule
|
||||
|
||||
This document should be reviewed:
|
||||
- After each phase completion
|
||||
- When adding new features
|
||||
- When fixing bugs
|
||||
- Monthly during active development
|
||||
|
||||
Last Updated: 2025-10-16 (Phase 3 - MVP Complete)
|
||||
@@ -0,0 +1,618 @@
|
||||
# Reklamator Deployment Guide
|
||||
|
||||
This guide covers deploying Reklamator to a production Linux server with security best practices.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Linux server (Ubuntu 22.04 LTS recommended)
|
||||
- Domain name with DNS configured
|
||||
- Root or sudo access
|
||||
- SSL/TLS certificate (Let's Encrypt recommended)
|
||||
|
||||
## System Requirements
|
||||
|
||||
- Python 3.11 or higher
|
||||
- 2GB RAM minimum (4GB recommended)
|
||||
- 20GB disk space (depends on feedback volume)
|
||||
- ClamAV for virus scanning
|
||||
- Nginx as reverse proxy
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. System Setup
|
||||
|
||||
```bash
|
||||
# Update system packages
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install required packages
|
||||
sudo apt install -y python3.11 python3.11-venv python3-pip nginx clamav clamav-daemon git
|
||||
|
||||
# Install certbot for Let's Encrypt SSL
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### 2. ClamAV Configuration
|
||||
|
||||
```bash
|
||||
# Stop ClamAV daemon
|
||||
sudo systemctl stop clamav-daemon
|
||||
|
||||
# Update virus definitions
|
||||
sudo freshclam
|
||||
|
||||
# Start and enable ClamAV daemon
|
||||
sudo systemctl start clamav-daemon
|
||||
sudo systemctl enable clamav-daemon
|
||||
|
||||
# Verify ClamAV is running
|
||||
sudo systemctl status clamav-daemon
|
||||
|
||||
# Test ClamAV socket
|
||||
clamdscan --version
|
||||
```
|
||||
|
||||
**ClamAV Configuration File** (`/etc/clamav/clamd.conf`):
|
||||
```conf
|
||||
# Uncomment this line if present
|
||||
LocalSocket /var/run/clamav/clamd.ctl
|
||||
|
||||
# Set appropriate permissions
|
||||
User clamav
|
||||
SocketGroup clamav
|
||||
SocketMode 666
|
||||
|
||||
# Increase timeouts for large files
|
||||
ReadTimeout 300
|
||||
CommandReadTimeout 30
|
||||
|
||||
# Memory limits
|
||||
MaxFileSize 25M
|
||||
MaxScanSize 100M
|
||||
```
|
||||
|
||||
### 3. Application Deployment
|
||||
|
||||
```bash
|
||||
# Create application user
|
||||
sudo useradd -r -s /bin/bash -m -d /opt/reklamator reklamator
|
||||
|
||||
# Switch to application user
|
||||
sudo su - reklamator
|
||||
|
||||
# Clone repository
|
||||
git clone <repository-url> /opt/reklamator/app
|
||||
cd /opt/reklamator/app
|
||||
|
||||
# Create virtual environment
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create data directory
|
||||
mkdir -p /opt/reklamator/data
|
||||
|
||||
# Exit back to root
|
||||
exit
|
||||
```
|
||||
|
||||
### 4. Environment Configuration
|
||||
|
||||
Create `/opt/reklamator/app/.env`:
|
||||
|
||||
```bash
|
||||
# Security (REQUIRED)
|
||||
SECRET_KEY=<generate-secure-key>
|
||||
ANTHROPIC_API_KEY=<your-api-key>
|
||||
|
||||
# Paths
|
||||
DATA_DIR=/opt/reklamator/data
|
||||
|
||||
# ClamAV
|
||||
CLAMD_SOCKET=/var/run/clamav/clamd.ctl
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_PER_HOUR=10
|
||||
|
||||
# File Upload
|
||||
MAX_CONTENT_LENGTH=10485760
|
||||
```
|
||||
|
||||
**Generate SECRET_KEY:**
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
Set proper permissions:
|
||||
```bash
|
||||
sudo chown reklamator:reklamator /opt/reklamator/app/.env
|
||||
sudo chmod 600 /opt/reklamator/app/.env
|
||||
```
|
||||
|
||||
### 5. Initialize Application
|
||||
|
||||
```bash
|
||||
# Switch to application user
|
||||
sudo su - reklamator
|
||||
cd /opt/reklamator/app
|
||||
source venv/bin/activate
|
||||
|
||||
# Initialize admin user
|
||||
python init_admin.py
|
||||
# Follow prompts to create admin user
|
||||
|
||||
# Create test product (optional)
|
||||
mkdir -p /opt/reklamator/data/products/test-product
|
||||
cat > /opt/reklamator/data/products/test-product/config.yaml <<EOF
|
||||
product_id: test-product
|
||||
name: Test Product
|
||||
submission_url_slug: test-feedback
|
||||
owner_language: en
|
||||
assigned_owner_ids:
|
||||
- admin
|
||||
status: active
|
||||
EOF
|
||||
|
||||
exit
|
||||
```
|
||||
|
||||
### 6. Systemd Service Configuration
|
||||
|
||||
Create `/etc/systemd/system/reklamator.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Reklamator Feedback Platform
|
||||
After=network.target clamav-daemon.service
|
||||
Requires=clamav-daemon.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=reklamator
|
||||
Group=reklamator
|
||||
WorkingDirectory=/opt/reklamator/app
|
||||
Environment="PATH=/opt/reklamator/app/venv/bin"
|
||||
Environment="FLASK_ENV=production"
|
||||
ExecStart=/opt/reklamator/app/venv/bin/python run.py
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/reklamator/data
|
||||
RestartSec=10
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start the service:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable reklamator
|
||||
sudo systemctl start reklamator
|
||||
sudo systemctl status reklamator
|
||||
```
|
||||
|
||||
### 7. Nginx Reverse Proxy Configuration
|
||||
|
||||
Create `/etc/nginx/sites-available/reklamator`:
|
||||
|
||||
```nginx
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name feedback.yourdomain.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name feedback.yourdomain.com;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/letsencrypt/live/feedback.yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/feedback.yourdomain.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security Headers (additional to Flask's HSTS)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Max upload size (must match Flask's MAX_CONTENT_LENGTH)
|
||||
client_max_body_size 35M; # 3 files × 10MB + overhead
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/reklamator_access.log;
|
||||
error_log /var/log/nginx/reklamator_error.log;
|
||||
|
||||
# Proxy to Flask app
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Don't buffer large uploads
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# Health check endpoint (no authentication)
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:5000/health;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/reklamator /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 8. SSL/TLS Certificate (Let's Encrypt)
|
||||
|
||||
```bash
|
||||
# Obtain certificate
|
||||
sudo certbot --nginx -d feedback.yourdomain.com
|
||||
|
||||
# Test auto-renewal
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
Certbot will automatically update the Nginx configuration with SSL settings.
|
||||
|
||||
### 9. Firewall Configuration
|
||||
|
||||
```bash
|
||||
# Allow SSH, HTTP, and HTTPS
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 'Nginx Full'
|
||||
sudo ufw enable
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
## Post-Deployment Verification
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
curl https://feedback.yourdomain.com/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-01-15T12:00:00Z",
|
||||
"environment": "production"
|
||||
}
|
||||
```
|
||||
|
||||
### Test Submission
|
||||
|
||||
1. Visit `https://feedback.yourdomain.com/submit/test-feedback`
|
||||
2. Submit test feedback
|
||||
3. Check logs: `sudo journalctl -u reklamator -f`
|
||||
4. Verify file creation: `ls -la /opt/reklamator/data/products/test-product/feedback/`
|
||||
|
||||
### Test Dashboard
|
||||
|
||||
1. Visit `https://feedback.yourdomain.com/login`
|
||||
2. Log in with admin credentials
|
||||
3. Verify dashboard loads: `https://feedback.yourdomain.com/dashboard`
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Application Logs
|
||||
|
||||
```bash
|
||||
# Real-time logs
|
||||
sudo journalctl -u reklamator -f
|
||||
|
||||
# Last 100 lines
|
||||
sudo journalctl -u reklamator -n 100
|
||||
|
||||
# Logs with timestamps
|
||||
sudo journalctl -u reklamator --since "1 hour ago"
|
||||
```
|
||||
|
||||
### Nginx Logs
|
||||
|
||||
```bash
|
||||
# Access logs
|
||||
sudo tail -f /var/log/nginx/reklamator_access.log
|
||||
|
||||
# Error logs
|
||||
sudo tail -f /var/log/nginx/reklamator_error.log
|
||||
```
|
||||
|
||||
### Health Monitoring
|
||||
|
||||
Set up automated health checks with your monitoring service:
|
||||
|
||||
```bash
|
||||
# Example with curl in cron
|
||||
*/5 * * * * curl -f https://feedback.yourdomain.com/health || echo "Reklamator health check failed" | mail -s "Alert: Reklamator Down" admin@yourdomain.com
|
||||
```
|
||||
|
||||
### Log Rotation
|
||||
|
||||
Create `/etc/logrotate.d/reklamator`:
|
||||
|
||||
```
|
||||
/opt/reklamator/app/*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 0640 reklamator reklamator
|
||||
sharedscripts
|
||||
}
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Database Backup (YAML Files)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /opt/reklamator/backup.sh
|
||||
|
||||
BACKUP_DIR="/opt/reklamator/backups"
|
||||
DATA_DIR="/opt/reklamator/data"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup data directory
|
||||
tar -czf $BACKUP_DIR/reklamator_data_$DATE.tar.gz -C $DATA_DIR .
|
||||
|
||||
# Keep only last 7 days
|
||||
find $BACKUP_DIR -name "reklamator_data_*.tar.gz" -mtime +7 -delete
|
||||
|
||||
echo "Backup completed: $BACKUP_DIR/reklamator_data_$DATE.tar.gz"
|
||||
```
|
||||
|
||||
Make executable and add to cron:
|
||||
```bash
|
||||
chmod +x /opt/reklamator/backup.sh
|
||||
sudo crontab -e -u reklamator
|
||||
# Add: 0 2 * * * /opt/reklamator/backup.sh
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Update Application
|
||||
|
||||
```bash
|
||||
sudo su - reklamator
|
||||
cd /opt/reklamator/app
|
||||
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Update dependencies
|
||||
pip install -r requirements.txt --upgrade
|
||||
|
||||
# Exit back to root
|
||||
exit
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart reklamator
|
||||
sudo systemctl status reklamator
|
||||
```
|
||||
|
||||
### Update ClamAV Virus Definitions
|
||||
|
||||
```bash
|
||||
# Manual update
|
||||
sudo freshclam
|
||||
|
||||
# Automatic updates are configured by default in /etc/clamav/freshclam.conf
|
||||
```
|
||||
|
||||
### Disk Space Management
|
||||
|
||||
Monitor feedback storage:
|
||||
```bash
|
||||
du -sh /opt/reklamator/data/products/*/feedback
|
||||
```
|
||||
|
||||
Archive old feedback:
|
||||
```bash
|
||||
# Example: Move feedback older than 1 year to archive
|
||||
find /opt/reklamator/data/products/*/feedback/ -type d -mtime +365 \
|
||||
-exec mv {} /opt/reklamator/archive/ \;
|
||||
```
|
||||
|
||||
## Security Hardening
|
||||
|
||||
### File Permissions
|
||||
|
||||
```bash
|
||||
# Application files
|
||||
sudo chown -R reklamator:reklamator /opt/reklamator/app
|
||||
sudo chmod -R 755 /opt/reklamator/app
|
||||
sudo chmod 600 /opt/reklamator/app/.env
|
||||
|
||||
# Data directory
|
||||
sudo chown -R reklamator:reklamator /opt/reklamator/data
|
||||
sudo chmod -R 750 /opt/reklamator/data
|
||||
```
|
||||
|
||||
### ClamAV Permissions
|
||||
|
||||
Add reklamator user to clamav group:
|
||||
```bash
|
||||
sudo usermod -a -G clamav reklamator
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Nginx can provide additional rate limiting:
|
||||
|
||||
```nginx
|
||||
# Add to http block in /etc/nginx/nginx.conf
|
||||
limit_req_zone $binary_remote_addr zone=submission:10m rate=10r/h;
|
||||
|
||||
# Add to location block for /submit/*
|
||||
location ~ ^/submit/ {
|
||||
limit_req zone=submission burst=2 nodelay;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
# ... other proxy settings
|
||||
}
|
||||
```
|
||||
|
||||
### Intrusion Detection
|
||||
|
||||
Install and configure fail2ban:
|
||||
```bash
|
||||
sudo apt install fail2ban
|
||||
|
||||
# Create /etc/fail2ban/jail.local
|
||||
[nginx-limit-req]
|
||||
enabled = true
|
||||
filter = nginx-limit-req
|
||||
logpath = /var/log/nginx/reklamator_error.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
sudo systemctl status reklamator
|
||||
|
||||
# Check logs
|
||||
sudo journalctl -u reklamator -n 50 --no-pager
|
||||
|
||||
# Common issues:
|
||||
# - Missing environment variables
|
||||
# - ClamAV not running
|
||||
# - Permissions on data directory
|
||||
# - Port 5000 already in use
|
||||
```
|
||||
|
||||
### ClamAV Connection Errors
|
||||
|
||||
```bash
|
||||
# Check ClamAV daemon status
|
||||
sudo systemctl status clamav-daemon
|
||||
|
||||
# Test socket connection
|
||||
clamdscan --version
|
||||
|
||||
# Check permissions
|
||||
ls -la /var/run/clamav/clamd.ctl
|
||||
|
||||
# Restart ClamAV
|
||||
sudo systemctl restart clamav-daemon
|
||||
```
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
```bash
|
||||
# Check memory usage
|
||||
free -h
|
||||
|
||||
# Restart application to clear memory leaks
|
||||
sudo systemctl restart reklamator
|
||||
|
||||
# Consider increasing server resources if processing large volumes
|
||||
```
|
||||
|
||||
### Slow AI Analysis
|
||||
|
||||
```bash
|
||||
# Check Anthropic API rate limits in logs
|
||||
sudo journalctl -u reklamator | grep "analysis"
|
||||
|
||||
# Consider increasing timeout in config/production.py
|
||||
# ANTHROPIC_API_TIMEOUT = 60 # seconds
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Gunicorn Configuration (Optional)
|
||||
|
||||
For production deployments with high traffic, use Gunicorn instead of Flask's development server.
|
||||
|
||||
Install Gunicorn:
|
||||
```bash
|
||||
sudo su - reklamator
|
||||
source /opt/reklamator/app/venv/bin/activate
|
||||
pip install gunicorn
|
||||
```
|
||||
|
||||
Update systemd service (`/etc/systemd/system/reklamator.service`):
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=/opt/reklamator/app/venv/bin/gunicorn -w 4 -b 127.0.0.1:5000 --timeout 60 'run:app'
|
||||
```
|
||||
|
||||
Restart:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart reklamator
|
||||
```
|
||||
|
||||
### Nginx Caching (Optional)
|
||||
|
||||
For static assets:
|
||||
```nginx
|
||||
location /static/ {
|
||||
alias /opt/reklamator/app/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For issues during deployment:
|
||||
1. Check application logs: `sudo journalctl -u reklamator -f`
|
||||
2. Check Nginx logs: `sudo tail -f /var/log/nginx/reklamator_error.log`
|
||||
3. Verify health endpoint: `curl https://feedback.yourdomain.com/health`
|
||||
4. Review configuration files for typos
|
||||
5. Ensure all environment variables are set in `.env`
|
||||
|
||||
For additional help, consult the main README.md or open an issue on GitHub.
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize admin user for Reklamator"""
|
||||
import os
|
||||
from app import create_app
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def init_admin():
|
||||
"""Create initial admin user if it doesn't exist"""
|
||||
app = create_app('development')
|
||||
|
||||
with app.app_context():
|
||||
# Check if admin user already exists
|
||||
existing_admin = User.get_by_username('admin')
|
||||
|
||||
if existing_admin:
|
||||
print("✓ Admin user already exists")
|
||||
print(f" Username: admin")
|
||||
print(f" Email: {existing_admin.email}")
|
||||
return
|
||||
|
||||
# Create admin user
|
||||
admin = User.create(
|
||||
username='admin',
|
||||
email='admin@reklamator.local',
|
||||
password='admin123',
|
||||
role='administrator',
|
||||
product_ids=[]
|
||||
)
|
||||
|
||||
print("✓ Admin user created successfully!")
|
||||
print(f" Username: admin")
|
||||
print(f" Password: admin123")
|
||||
print(f" Email: {admin.email}")
|
||||
print(f" User ID: {admin.user_id}")
|
||||
print("")
|
||||
print("⚠️ IMPORTANT: Change the password in production!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_admin()
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
markers =
|
||||
contract: Contract tests for API endpoints
|
||||
integration: Integration tests for user journeys
|
||||
unit: Unit tests for isolated components
|
||||
performance: Performance tests for scalability and load testing
|
||||
@@ -0,0 +1,21 @@
|
||||
# Development dependencies for Reklamator
|
||||
# Install with: pip install -r requirements-dev.txt
|
||||
|
||||
# Linting and code quality
|
||||
ruff==0.1.9
|
||||
black==23.12.1
|
||||
flake8==7.0.0
|
||||
mypy==1.7.1
|
||||
|
||||
# Testing
|
||||
pytest==7.4.3
|
||||
pytest-flask==1.3.0
|
||||
pytest-cov==4.1.0
|
||||
pytest-mock==3.12.0
|
||||
|
||||
# Debugging
|
||||
ipdb==0.13.13
|
||||
ipython==8.19.0
|
||||
|
||||
# Load testing
|
||||
locust==2.20.0
|
||||
@@ -0,0 +1,12 @@
|
||||
Flask==3.0.0
|
||||
Flask-Login==0.6.3
|
||||
Flask-Limiter==3.5.0
|
||||
Flask-WTF==1.2.1
|
||||
anthropic==0.71.0
|
||||
clamd==1.0.2
|
||||
bcrypt==4.1.2
|
||||
PyYAML==6.0.1
|
||||
pytest==7.4.3
|
||||
pytest-flask==1.3.0
|
||||
python-dotenv==1.0.0
|
||||
Werkzeug==3.0.1
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reklamator - Anonymous Feedback Platform
|
||||
Application entry point
|
||||
"""
|
||||
import os
|
||||
from app import create_app
|
||||
|
||||
app = create_app(os.getenv('FLASK_ENV', 'development'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
@@ -0,0 +1,82 @@
|
||||
# Specification Quality Checklist: Anonymous Feedback Platform (Reklamator)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2025-10-14
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Validation Results
|
||||
|
||||
### Content Quality Review
|
||||
|
||||
✅ **PASS** - The specification is free of implementation details. All requirements focus on what the system must do, not how it should be implemented. Technology choices (AI service, storage mechanism) are mentioned only in Assumptions section where appropriate.
|
||||
|
||||
✅ **PASS** - The specification centers on user value: anonymous feedback submission, AI-powered analysis for product owners, and efficient dashboard access. Business needs are clearly articulated.
|
||||
|
||||
✅ **PASS** - Language is accessible to non-technical stakeholders. Technical jargon is minimal and necessary terms (e.g., "API timeout") are used only in edge cases.
|
||||
|
||||
✅ **PASS** - All mandatory sections are present and complete: User Scenarios & Testing, Requirements, Success Criteria.
|
||||
|
||||
### Requirement Completeness Review
|
||||
|
||||
✅ **PASS** - No [NEEDS CLARIFICATION] markers remain in the specification. All ambiguities have been resolved with reasonable defaults.
|
||||
|
||||
✅ **PASS** - All 64 functional requirements are testable and unambiguous. Each requirement uses clear language (MUST) and specific criteria (e.g., "10MB per file", "10,000 characters", "3 files maximum").
|
||||
|
||||
✅ **PASS** - Success criteria include specific metrics: completion times (under 1 minute, under 30 seconds), accuracy thresholds (80%, 99%), performance targets (1000 items, 100 concurrent users), and qualitative measures (translation comprehensibility).
|
||||
|
||||
✅ **PASS** - Success criteria are technology-agnostic, focusing on user-observable outcomes like "users can submit in under 1 minute" rather than "API response time is X ms".
|
||||
|
||||
✅ **PASS** - Each user story includes detailed acceptance scenarios in Given-When-Then format covering normal flows, edge cases, and error conditions.
|
||||
|
||||
✅ **PASS** - Edge cases section identifies 13 specific boundary conditions and error scenarios to be addressed during implementation.
|
||||
|
||||
✅ **PASS** - Scope is clearly bounded with comprehensive "Out of Scope" section listing 15 items explicitly excluded (multilingual UI, real-time chat, mobile apps, advanced analytics, etc.).
|
||||
|
||||
✅ **PASS** - Assumptions section lists 15 explicit assumptions about technology choices, operational constraints, and scale expectations. Dependencies are implicit in user story priorities.
|
||||
|
||||
### Feature Readiness Review
|
||||
|
||||
✅ **PASS** - All 64 functional requirements are traceable to acceptance scenarios in the user stories. Requirements are organized by functional area for clarity.
|
||||
|
||||
✅ **PASS** - Four user stories cover the complete feature lifecycle: feedback submission (P1), AI analysis (P2), dashboard access (P3), and product management (P4). Each story is independently testable.
|
||||
|
||||
✅ **PASS** - The specification defines 14 measurable success criteria that will determine if the feature meets its goals.
|
||||
|
||||
✅ **PASS** - No implementation details are present in the requirements. Storage mechanism, AI service choice, and authentication method are appropriately deferred to planning phase.
|
||||
|
||||
## Notes
|
||||
|
||||
- Specification is ready for `/speckit.plan` phase
|
||||
- All quality criteria passed on first validation
|
||||
- User stories are properly prioritized and independently testable
|
||||
- Clear separation maintained between WHAT (requirements) and HOW (implementation)
|
||||
- Reasonable defaults applied for file size limits, character limits, and rate limiting based on standard practices
|
||||
|
||||
## Recommendation
|
||||
|
||||
✅ **APPROVED** - Specification meets all quality criteria and is ready to proceed to implementation planning phase.
|
||||
@@ -0,0 +1,605 @@
|
||||
# Admin Routes Contract
|
||||
|
||||
**Scope**: Product and user management web routes (User Story P4)
|
||||
**Authentication**: Required (admin role only)
|
||||
**Response Type**: Server-rendered HTML (no JavaScript required)
|
||||
|
||||
---
|
||||
|
||||
## GET /admin/products
|
||||
|
||||
Display list of all registered products.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Product Management</title></head>
|
||||
<body>
|
||||
<h1>Product Management</h1>
|
||||
|
||||
<a href="/admin/products/new">+ Create New Product</a>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Submission URL</th>
|
||||
<th>Target Language</th>
|
||||
<th>Feedback Count</th>
|
||||
<th>Assigned Owners</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>001-acme-app</td>
|
||||
<td>Acme Mobile App</td>
|
||||
<td>Active</td>
|
||||
<td><a href="/submit/acme-app">/submit/acme-app</a></td>
|
||||
<td>English (en)</td>
|
||||
<td>127</td>
|
||||
<td>2 owners</td>
|
||||
<td>
|
||||
<a href="/admin/products/001-acme-app/edit">Edit</a> |
|
||||
<a href="/admin/products/001-acme-app/archive">Archive</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- More rows... -->
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (403 Forbidden)**: User is not an administrator
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Access Denied</h1>
|
||||
<p>Administrator privileges required.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-045: List all products
|
||||
- FR-054: Display product statistics
|
||||
|
||||
---
|
||||
|
||||
## GET /admin/products/new
|
||||
|
||||
Display form to create a new product.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Create New Product</h1>
|
||||
<form method="POST" action="/admin/products">
|
||||
<label>Product ID (URL-safe):
|
||||
<input type="text" name="id" pattern="[a-z0-9-]+" required placeholder="001-my-product">
|
||||
</label>
|
||||
|
||||
<label>Name:
|
||||
<input type="text" name="name" maxlength="100" required placeholder="My Product">
|
||||
</label>
|
||||
|
||||
<label>Description:
|
||||
<textarea name="description" maxlength="500" placeholder="Optional description"></textarea>
|
||||
</label>
|
||||
|
||||
<label>Target Language for Translations:
|
||||
<select name="target_language" required>
|
||||
<option value="en">English</option>
|
||||
<option value="de">German</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<!-- More languages... -->
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>Submission URL Slug:
|
||||
<input type="text" name="submission_url_slug" pattern="[a-z0-9-]+" required placeholder="my-product">
|
||||
</label>
|
||||
|
||||
<label>Assigned Product Owners:
|
||||
<select name="assigned_owner_ids" multiple>
|
||||
<option value="owner-001">Jane Smith (jane.smith@example.com)</option>
|
||||
<option value="owner-002">John Doe (john.doe@example.com)</option>
|
||||
<!-- More owners... -->
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit">Create Product</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-045: Form to register new products
|
||||
- FR-047: Set preferred target language
|
||||
- FR-048: Assign product owners
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/products
|
||||
|
||||
Create a new product.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Form Data**:
|
||||
- `id` (string, required): Unique product identifier (URL-safe, lowercase, hyphens allowed)
|
||||
- `name` (string, required): Display name (1-100 characters)
|
||||
- `description` (string, optional): Description (max 500 characters)
|
||||
- `target_language` (string, required): ISO 639-1 language code
|
||||
- `submission_url_slug` (string, required): URL-safe slug (unique)
|
||||
- `assigned_owner_ids` (string[], required): At least one product owner ID
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/products
|
||||
```
|
||||
|
||||
**Error (400 Bad Request)**: Validation failure
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Validation Error</h1>
|
||||
<ul>
|
||||
<li>Product ID must be unique</li>
|
||||
<li>Product ID must be URL-safe (lowercase, hyphens only)</li>
|
||||
<li>Submission URL slug must be unique</li>
|
||||
<li>At least one product owner must be assigned</li>
|
||||
<li>Target language must be valid ISO 639-1 code</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Side Effects
|
||||
|
||||
1. **File System**:
|
||||
- Creates `data/products/{product_id}/`
|
||||
- Writes `data/products/{product_id}/config.yaml`
|
||||
- Creates `data/products/{product_id}/feedback/` directory
|
||||
|
||||
2. **Config File** (`config.yaml`):
|
||||
```yaml
|
||||
id: "001-acme-app"
|
||||
name: "Acme Mobile App"
|
||||
description: "Customer feedback for Acme's flagship mobile application"
|
||||
target_language: "en"
|
||||
submission_url_slug: "acme-app"
|
||||
created_date: "2025-10-15"
|
||||
status: "active"
|
||||
assigned_owner_ids:
|
||||
- "owner-001"
|
||||
- "owner-002"
|
||||
statistics:
|
||||
total_feedback_count: 0
|
||||
last_submission: null
|
||||
```
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-045: Register new products
|
||||
- FR-046: Unique product identifier
|
||||
- FR-047: Set target language
|
||||
- FR-048: Assign product owners
|
||||
- FR-049: Generate unique submission URL
|
||||
|
||||
---
|
||||
|
||||
## GET /admin/products/{product_id}/edit
|
||||
|
||||
Display form to edit an existing product.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Path Parameters**:
|
||||
- `product_id` (string, required): Product identifier
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**: Same form as create, pre-populated with existing values
|
||||
|
||||
**Error (404 Not Found)**: Product does not exist
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-051: Update product details
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/products/{product_id}
|
||||
|
||||
Update an existing product.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Path Parameters**:
|
||||
- `product_id` (string, required): Product identifier
|
||||
|
||||
**Form Data**: Same as POST /admin/products (except `id` is immutable)
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/products
|
||||
```
|
||||
|
||||
**Error (400 Bad Request)**: Validation failure
|
||||
**Error (404 Not Found)**: Product does not exist
|
||||
|
||||
### Side Effects
|
||||
|
||||
- Updates `data/products/{product_id}/config.yaml`
|
||||
- Product `id` cannot be changed (immutable)
|
||||
- Changing `target_language` affects future feedback translations only (FR-048)
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-048: Update product owner assignments
|
||||
- FR-051: Update product details
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/products/{product_id}/archive
|
||||
|
||||
Archive a product (stop accepting new feedback).
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Path Parameters**:
|
||||
- `product_id` (string, required): Product identifier
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/products
|
||||
```
|
||||
|
||||
**Error (404 Not Found)**: Product does not exist
|
||||
|
||||
### Side Effects
|
||||
|
||||
- Updates `data/products/{product_id}/config.yaml`: Sets `status: "archived"`
|
||||
- Submission form (GET /submit/{product_slug}) returns 404 for archived products (FR-053)
|
||||
- Historical feedback preserved (FR-052)
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-052: Archive products without deleting feedback
|
||||
- FR-053: Prevent new submissions to archived products
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/products/{product_id}/unarchive
|
||||
|
||||
Reactivate an archived product.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Path Parameters**:
|
||||
- `product_id` (string, required): Product identifier
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/products
|
||||
```
|
||||
|
||||
### Side Effects
|
||||
|
||||
- Updates `data/products/{product_id}/config.yaml`: Sets `status: "active"`
|
||||
- Submission form becomes available again
|
||||
|
||||
### Functional Requirements Covered
|
||||
- Allow reversing archive operation (not explicitly in FR but useful)
|
||||
|
||||
---
|
||||
|
||||
## GET /admin/users
|
||||
|
||||
Display list of all users (product owners and admins).
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>User Management</h1>
|
||||
|
||||
<a href="/admin/users/new">+ Create New User</a>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Assigned Products</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>owner-001</td>
|
||||
<td>jane.smith@example.com</td>
|
||||
<td>Jane Smith</td>
|
||||
<td>Product Owner</td>
|
||||
<td>2 products</td>
|
||||
<td>2025-10-15 09:23</td>
|
||||
<td>
|
||||
<a href="/admin/users/owner-001/edit">Edit</a> |
|
||||
<a href="/admin/users/owner-001/delete">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- More rows... -->
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Functional Requirements Covered
|
||||
- User management interface (implied by FR-048: assigning owners)
|
||||
|
||||
---
|
||||
|
||||
## GET /admin/users/new
|
||||
|
||||
Display form to create a new user.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Create New User</h1>
|
||||
<form method="POST" action="/admin/users">
|
||||
<label>Email:
|
||||
<input type="email" name="email" required>
|
||||
</label>
|
||||
|
||||
<label>Name:
|
||||
<input type="text" name="name" maxlength="100" required>
|
||||
</label>
|
||||
|
||||
<label>Password:
|
||||
<input type="password" name="password" minlength="8" required>
|
||||
</label>
|
||||
|
||||
<label>Role:
|
||||
<select name="role" required>
|
||||
<option value="product_owner">Product Owner</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit">Create User</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/users
|
||||
|
||||
Create a new user.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Form Data**:
|
||||
- `email` (string, required): Valid email address (unique)
|
||||
- `name` (string, required): Display name (1-100 characters)
|
||||
- `password` (string, required): Password (min 8 characters)
|
||||
- `role` (string, required): "product_owner" or "admin"
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/users
|
||||
```
|
||||
|
||||
**Error (400 Bad Request)**: Validation failure
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Validation Error</h1>
|
||||
<ul>
|
||||
<li>Email must be unique</li>
|
||||
<li>Password must be at least 8 characters</li>
|
||||
<li>Invalid role specified</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Side Effects
|
||||
|
||||
- Appends new user to `data/users.yaml`
|
||||
- Password hashed with bcrypt (cost factor 12) before storage (FR-063)
|
||||
- Generates unique user ID (e.g., "owner-001", "admin-002")
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-063: Secure password storage (bcrypt)
|
||||
- User creation for product owner assignment
|
||||
|
||||
---
|
||||
|
||||
## GET /admin/users/{user_id}/edit
|
||||
|
||||
Display form to edit an existing user.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Path Parameters**:
|
||||
- `user_id` (string, required): User identifier
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**: Same form as create, pre-populated (except password field empty)
|
||||
|
||||
**Error (404 Not Found)**: User does not exist
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/users/{user_id}
|
||||
|
||||
Update an existing user.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Path Parameters**:
|
||||
- `user_id` (string, required): User identifier
|
||||
|
||||
**Form Data**:
|
||||
- `email` (string, required): Valid email address
|
||||
- `name` (string, required): Display name
|
||||
- `password` (string, optional): New password (if changing)
|
||||
- `role` (string, required): "product_owner" or "admin"
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/users
|
||||
```
|
||||
|
||||
### Side Effects
|
||||
|
||||
- Updates user entry in `data/users.yaml`
|
||||
- If password provided, re-hash with bcrypt
|
||||
- Email and role can be updated
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/users/{user_id}/delete
|
||||
|
||||
Delete a user.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (admin role)
|
||||
|
||||
**Path Parameters**:
|
||||
- `user_id` (string, required): User identifier
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/users
|
||||
```
|
||||
|
||||
**Error (400 Bad Request)**: Cannot delete self
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Cannot Delete</h1>
|
||||
<p>You cannot delete your own account.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Side Effects
|
||||
|
||||
- Removes user from `data/users.yaml`
|
||||
- User automatically unassigned from all products
|
||||
- Historical feedback metadata unchanged (no user tracking in feedback)
|
||||
|
||||
---
|
||||
|
||||
## Access Control
|
||||
|
||||
All admin endpoints enforce:
|
||||
1. User must be authenticated (session cookie)
|
||||
2. User role must be "admin"
|
||||
3. Otherwise: 403 Forbidden response
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-045: Admin can register products
|
||||
- FR-048: Admin can assign product owners
|
||||
- FR-051: Admin can update products
|
||||
- FR-052: Admin can archive products
|
||||
@@ -0,0 +1,421 @@
|
||||
# Dashboard Routes Contract
|
||||
|
||||
**Scope**: Product owner dashboard web routes (User Story P3)
|
||||
**Authentication**: Required (session-based via Flask-Login)
|
||||
**Response Type**: Server-rendered HTML (no JavaScript required)
|
||||
|
||||
---
|
||||
|
||||
## GET /login
|
||||
|
||||
Display login form for product owners and administrators.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers**: None required
|
||||
|
||||
**Query Parameters**:
|
||||
- `next` (string, optional): Redirect URL after successful login
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login - Reklamator</title></head>
|
||||
<body>
|
||||
<h1>Login</h1>
|
||||
<form method="POST" action="/login">
|
||||
<input type="email" name="email" required placeholder="Email">
|
||||
<input type="password" name="password" required placeholder="Password">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Already Authenticated (302 Redirect)**: Redirect to `/dashboard`
|
||||
|
||||
---
|
||||
|
||||
## POST /login
|
||||
|
||||
Authenticate product owner or administrator.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers**:
|
||||
- `Content-Type: application/x-www-form-urlencoded`
|
||||
|
||||
**Form Data**:
|
||||
- `email` (string, required): User email
|
||||
- `password` (string, required): User password
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /dashboard
|
||||
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
|
||||
```
|
||||
|
||||
**Error (401 Unauthorized)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Invalid email or password.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-056: Authentication required for dashboard
|
||||
- FR-063: Password verification against bcrypt hash
|
||||
|
||||
---
|
||||
|
||||
## GET /logout
|
||||
|
||||
Log out current user.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (session cookie)
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /login
|
||||
Set-Cookie: session=deleted; expires=Thu, 01 Jan 1970 00:00:00 GMT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /dashboard
|
||||
|
||||
Display product owner dashboard with feedback list.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (session cookie)
|
||||
|
||||
**Query Parameters**:
|
||||
- `page` (integer, optional, default=1): Page number for pagination
|
||||
- `category` (string, optional): Filter by category (idea/feature_request/bug/complaint)
|
||||
- `language` (string, optional): Filter by original language (ISO 639-1 code)
|
||||
- `status` (string, optional): Filter by status (analyzed/reviewed/in_progress/resolved/rejected)
|
||||
- `date_from` (string, optional): Filter by date range start (ISO 8601 date)
|
||||
- `date_to` (string, optional): Filter by date range end (ISO 8601 date)
|
||||
- `search` (string, optional): Keyword search across text/translation/summary
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Feedback Dashboard</title></head>
|
||||
<body>
|
||||
<h1>Feedback Dashboard</h1>
|
||||
|
||||
<!-- Product selector if multiple products assigned -->
|
||||
<select name="product">
|
||||
<option value="001-acme-app">Acme Mobile App (127 items)</option>
|
||||
<option value="002-beta-service">Beta Service (43 items)</option>
|
||||
</select>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="GET" action="/dashboard">
|
||||
<select name="category">
|
||||
<option value="">All Categories</option>
|
||||
<option value="idea">Ideas</option>
|
||||
<option value="feature_request">Feature Requests</option>
|
||||
<option value="bug">Bugs</option>
|
||||
<option value="complaint">Complaints</option>
|
||||
</select>
|
||||
<select name="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="analyzed">Analyzed</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<input type="text" name="search" placeholder="Search feedback...">
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
<!-- Feedback list -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Category</th>
|
||||
<th>Original Lang</th>
|
||||
<th>Summary</th>
|
||||
<th>Status</th>
|
||||
<th>Attachments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="/feedback/a3f2c1d5">a3f2c1d5</a></td>
|
||||
<td>2025-10-15 14:32</td>
|
||||
<td>Bug</td>
|
||||
<td>DE</td>
|
||||
<td>User reports app crashes when uploading large files...</td>
|
||||
<td>Reviewed</td>
|
||||
<td>2 files</td>
|
||||
</tr>
|
||||
<!-- More rows... -->
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination">
|
||||
<a href="/dashboard?page=1">1</a>
|
||||
<a href="/dashboard?page=2">2</a>
|
||||
<a href="/dashboard?page=3">3</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (401 Unauthorized)**: Not authenticated
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /login?next=/dashboard
|
||||
```
|
||||
|
||||
**Error (403 Forbidden)**: User has no assigned products
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>No Access</h1>
|
||||
<p>You are not assigned to any products.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- Display only feedback for products assigned to current user (FR-033)
|
||||
- Admin users see all products
|
||||
- Default sort: newest first (FR-041)
|
||||
- Pagination: 50 items per page (SC-008: <3s for 1000 items)
|
||||
- Filters preserved in URL for sharing/bookmarking
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-032: Authenticated dashboard access
|
||||
- FR-033: Product owner access control
|
||||
- FR-034: Display all analysis results
|
||||
- FR-036-FR-041: Filtering and searching
|
||||
- FR-041: Reverse chronological order
|
||||
|
||||
---
|
||||
|
||||
## GET /feedback/{feedback_id}
|
||||
|
||||
Display detailed view of a single feedback item.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (session cookie)
|
||||
|
||||
**Path Parameters**:
|
||||
- `feedback_id` (UUID, required): Feedback identifier
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Feedback Detail - a3f2c1d5</title></head>
|
||||
<body>
|
||||
<h1>Feedback Detail</h1>
|
||||
|
||||
<div class="metadata">
|
||||
<p><strong>ID:</strong> a3f2c1d5-8b4e-4f1a-9c2d-7e6f5a4b3c2d</p>
|
||||
<p><strong>Product:</strong> Acme Mobile App</p>
|
||||
<p><strong>Submitted:</strong> 2025-10-15 14:32:10 UTC</p>
|
||||
<p><strong>Original Language:</strong> German (DE)</p>
|
||||
<p><strong>Category:</strong> Bug (confidence: 0.92)</p>
|
||||
<p><strong>Status:</strong>
|
||||
<form method="POST" action="/feedback/a3f2c1d5/status">
|
||||
<select name="status">
|
||||
<option value="analyzed">Analyzed</option>
|
||||
<option value="reviewed" selected>Reviewed</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<button type="submit">Update Status</button>
|
||||
</form>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>AI Analysis Summary</h2>
|
||||
<p>User reports that the app crashes when uploading large files. This appears to be a bug affecting the file upload module, preventing users from submitting documents over 5MB.</p>
|
||||
|
||||
<h2>Original Text (German)</h2>
|
||||
<pre>Die App stürzt ab, wenn ich versuche, große Dateien hochzuladen. Jedes Mal wenn ich eine PDF über 5MB hochlade, friert die App ein und schließt sich.</pre>
|
||||
|
||||
<h2>Translation (English)</h2>
|
||||
<pre>The app crashes when I try to upload large files. Every time I upload a PDF over 5MB, the app freezes and closes.</pre>
|
||||
|
||||
<h2>Attachments</h2>
|
||||
<ul>
|
||||
<li><a href="/feedback/a3f2c1d5/attachment/screenshot.png" target="_blank">screenshot.png</a> (245 KB)</li>
|
||||
<li><a href="/feedback/a3f2c1d5/attachment/error_log.txt" target="_blank">error_log.txt</a> (1 KB)</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (401 Unauthorized)**: Not authenticated
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /login?next=/feedback/{feedback_id}
|
||||
```
|
||||
|
||||
**Error (403 Forbidden)**: User not authorized for this product
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Access Denied</h1>
|
||||
<p>You do not have permission to view this feedback.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (404 Not Found)**: Feedback does not exist
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Feedback Not Found</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-034: Display complete feedback details
|
||||
- FR-035: Links to download attachments
|
||||
- FR-042: Status indicators
|
||||
- FR-044: File attachments with icons/thumbnails
|
||||
|
||||
---
|
||||
|
||||
## POST /feedback/{feedback_id}/status
|
||||
|
||||
Update the status of a feedback item.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (session cookie)
|
||||
|
||||
**Path Parameters**:
|
||||
- `feedback_id` (UUID, required): Feedback identifier
|
||||
|
||||
**Form Data**:
|
||||
- `status` (string, required): New status (analyzed/reviewed/in_progress/resolved/rejected)
|
||||
|
||||
### Response
|
||||
|
||||
**Success (302 Redirect)**:
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /feedback/{feedback_id}
|
||||
```
|
||||
|
||||
**Error (403 Forbidden)**: User not authorized for this product
|
||||
**Error (404 Not Found)**: Feedback does not exist
|
||||
|
||||
### Side Effects
|
||||
|
||||
- Updates `metadata.yaml`: `status` field
|
||||
- Preserves timestamp of status change
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-042: Mark feedback with status indicators
|
||||
- FR-043: Preserve status when filtering
|
||||
|
||||
---
|
||||
|
||||
## GET /feedback/{feedback_id}/attachment/{filename}
|
||||
|
||||
Download or view an attached file.
|
||||
|
||||
### Request
|
||||
|
||||
**Authentication**: Required (session cookie)
|
||||
|
||||
**Path Parameters**:
|
||||
- `feedback_id` (UUID, required): Feedback identifier
|
||||
- `filename` (string, required): Sanitized filename
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**: Image file
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: image/png
|
||||
Content-Disposition: inline; filename="screenshot.png"
|
||||
Content-Length: 245678
|
||||
|
||||
[binary image data]
|
||||
```
|
||||
|
||||
**Success (200 OK)**: Document file
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/pdf
|
||||
Content-Disposition: attachment; filename="report.pdf"
|
||||
Content-Length: 1234567
|
||||
|
||||
[binary document data]
|
||||
```
|
||||
|
||||
**Error (403 Forbidden)**: User not authorized for this product
|
||||
**Error (404 Not Found)**: File does not exist
|
||||
|
||||
### Security
|
||||
|
||||
- Files served via Flask route (not direct filesystem access per FR-058)
|
||||
- Access control enforced: User must have access to parent product
|
||||
- Path traversal prevention: Filename sanitized
|
||||
- MIME type from stored metadata (not client-provided)
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-035: Provide links to download/view attachments
|
||||
- FR-058: Prevent unauthorized file access
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Dashboard endpoints are NOT rate limited (authenticated users only).
|
||||
@@ -0,0 +1,220 @@
|
||||
# Submission Routes Contract
|
||||
|
||||
**Scope**: Anonymous feedback submission web routes (User Story P1)
|
||||
**Authentication**: None (anonymous access)
|
||||
**Response Type**: Server-rendered HTML (no JavaScript required)
|
||||
|
||||
---
|
||||
|
||||
## GET /submit/{product_slug}
|
||||
|
||||
Display the feedback submission form for a specific product.
|
||||
|
||||
### Request
|
||||
|
||||
**Path Parameters**:
|
||||
- `product_slug` (string, required): Product's URL-safe identifier
|
||||
|
||||
**Headers**: None required
|
||||
|
||||
**Query Parameters**: None
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Submit Feedback - {Product Name}</title></head>
|
||||
<body>
|
||||
<h1>Submit Feedback for {Product Name}</h1>
|
||||
<form method="POST" action="/submit/{product_slug}" enctype="multipart/form-data">
|
||||
<textarea name="feedback_text" maxlength="10000"></textarea>
|
||||
<input type="file" name="attachments" multiple accept=".pdf,.docx,.txt,.jpg,.png,.gif,.webp">
|
||||
<button type="submit">Submit Feedback</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (404 Not Found)**: Product does not exist or is archived
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Product Not Found</h1>
|
||||
<p>The product you're looking for does not exist or is no longer accepting feedback.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-001: Public, unauthenticated submission form
|
||||
- FR-002: Text input up to 10,000 characters
|
||||
- FR-004: Up to 3 file attachments
|
||||
|
||||
---
|
||||
|
||||
## POST /submit/{product_slug}
|
||||
|
||||
Submit anonymous feedback for a specific product.
|
||||
|
||||
### Request
|
||||
|
||||
**Path Parameters**:
|
||||
- `product_slug` (string, required): Product's URL-safe identifier
|
||||
|
||||
**Headers**:
|
||||
- `Content-Type: multipart/form-data`
|
||||
|
||||
**Form Data**:
|
||||
- `feedback_text` (string, optional): Feedback text (0-10,000 characters)
|
||||
- `attachments` (file[], optional): Up to 3 files, max 10MB each
|
||||
|
||||
**Example**:
|
||||
```http
|
||||
POST /submit/acme-app HTTP/1.1
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
||||
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="feedback_text"
|
||||
|
||||
The app crashes when uploading files over 5MB.
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="attachments"; filename="screenshot.png"
|
||||
Content-Type: image/png
|
||||
|
||||
[binary data]
|
||||
------WebKitFormBoundary--
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Success (200 OK)**:
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Thank You!</h1>
|
||||
<p>Your feedback has been submitted successfully.</p>
|
||||
<p>Your feedback ID: {feedback_id}</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (400 Bad Request)**: Validation failure
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Submission Error</h1>
|
||||
<ul>
|
||||
<li>Feedback must contain text or at least one attachment</li>
|
||||
<li>Maximum 3 attachments allowed</li>
|
||||
<li>Text cannot exceed 10,000 characters</li>
|
||||
<li>File size cannot exceed 10MB per file</li>
|
||||
<li>Unsupported file type: {filename}</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (413 Payload Too Large)**: File size exceeds limit
|
||||
```html
|
||||
Content-Type: text/html
|
||||
HTTP/1.1 413 Payload Too Large
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>File Too Large</h1>
|
||||
<p>One or more files exceed the 10MB limit.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (429 Too Many Requests)**: Rate limit exceeded
|
||||
```html
|
||||
Content-Type: text/html
|
||||
X-RateLimit-Limit: 10
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1697456789
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Too Many Submissions</h1>
|
||||
<p>You have exceeded the submission limit of 10 per hour. Please try again later.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Error (451 Unavailable For Legal Reasons)**: Malware detected
|
||||
```html
|
||||
Content-Type: text/html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Security Error</h1>
|
||||
<p>One or more files failed security scanning. Please ensure your files are safe and try again.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Validation Rules
|
||||
|
||||
1. **Text Validation** (FR-002, FR-003):
|
||||
- Length: 0-10,000 characters
|
||||
- Encoding: UTF-8, any language accepted
|
||||
- Empty allowed if attachments present
|
||||
|
||||
2. **Attachment Validation** (FR-004, FR-005, FR-006):
|
||||
- Count: 0-3 files
|
||||
- Size: Max 10MB per file
|
||||
- Types: PDF, DOCX, TXT, JPG, PNG, GIF, WebP
|
||||
- MIME type validation (server-side)
|
||||
|
||||
3. **Submission Validation** (FR-011, FR-012):
|
||||
- Must have text OR attachments (not both empty)
|
||||
|
||||
4. **Security** (FR-059, FR-060):
|
||||
- ClamAV virus scan before storage
|
||||
- Filename sanitization (remove path traversal)
|
||||
- MIME type validation
|
||||
|
||||
5. **Rate Limiting** (FR-061):
|
||||
- 10 submissions per hour per IP address
|
||||
|
||||
### Functional Requirements Covered
|
||||
- FR-001 to FR-012: Complete submission flow
|
||||
- FR-055: No IP/session tracking stored
|
||||
- FR-059, FR-060: File security
|
||||
- FR-061: Rate limiting
|
||||
|
||||
### Side Effects
|
||||
|
||||
1. **File System**:
|
||||
- Creates `data/products/{product_id}/feedback/{feedback_id}/`
|
||||
- Writes `metadata.yaml`, `content.txt`
|
||||
- Writes `attachments/{filename}` if files uploaded
|
||||
|
||||
2. **Async Processing**:
|
||||
- Triggers AI analysis background job
|
||||
- Updates feedback status: `submitted` → `analyzing`
|
||||
|
||||
---
|
||||
|
||||
## POST /submit/{product_slug}/status/{feedback_id}
|
||||
|
||||
**Note**: This endpoint is OUT OF SCOPE for MVP. Feedback submission is fire-and-forget. Users cannot track submission status anonymously.
|
||||
|
||||
Future consideration: Anonymous status check via feedback ID (requires balancing anonymity with user experience).
|
||||
@@ -0,0 +1,410 @@
|
||||
# Data Model: Anonymous Feedback Platform (Reklamator)
|
||||
|
||||
**Branch**: `001-build-an-application` | **Date**: 2025-10-15
|
||||
|
||||
This document defines the domain entities, their attributes, relationships, validation rules, and state transitions for the Reklamator application.
|
||||
|
||||
## Entity Overview
|
||||
|
||||
```
|
||||
Product (1) ----< (N) Feedback
|
||||
| |
|
||||
| |---< (N) Attachment
|
||||
| |
|
||||
| |---- (1) AnalysisResult
|
||||
|
|
||||
|----< (N) ProductOwner
|
||||
|
||||
Administrator (manages all entities)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entity Definitions
|
||||
|
||||
### 1. Feedback Submission
|
||||
|
||||
**Description**: Represents a single feedback item submitted by an anonymous user.
|
||||
|
||||
**Storage Location**: `data/products/{product_id}/feedback/{feedback_id}/`
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Field | Type | Required | Validation | Description |
|
||||
|-------|------|----------|------------|-------------|
|
||||
| `id` | UUID v4 | Yes | Auto-generated | Unique identifier |
|
||||
| `product_id` | String | Yes | Must reference existing product | Associated product identifier |
|
||||
| `original_text` | String | Yes* | 1-10,000 characters | Original feedback text (*empty if file-only submission) |
|
||||
| `original_language` | String (ISO 639-1) | No | 2-char code | Detected language (e.g., "en", "de", "ja") |
|
||||
| `submission_timestamp` | ISO 8601 DateTime | Yes | Auto-generated | When feedback was submitted (UTC) |
|
||||
| `status` | Enum | Yes | See Status enum below | Current processing/review status |
|
||||
| `category` | Enum | No | See Category enum below | AI-assigned category (null if analysis pending/failed) |
|
||||
| `attachment_count` | Integer | Yes | 0-3 | Number of attached files |
|
||||
|
||||
#### Status Enum
|
||||
- `submitted` - Initial state after successful submission
|
||||
- `analyzing` - AI analysis in progress
|
||||
- `analysis_failed` - AI analysis encountered error
|
||||
- `analyzed` - AI analysis completed successfully
|
||||
- `reviewed` - Product owner has reviewed
|
||||
- `in_progress` - Product owner marked as being worked on
|
||||
- `resolved` - Product owner marked as resolved
|
||||
- `rejected` - Product owner marked as not actionable
|
||||
|
||||
#### Category Enum (AI-assigned)
|
||||
- `idea` - New concept or suggestion
|
||||
- `feature_request` - Request for specific functionality
|
||||
- `bug` - Problem or defect report
|
||||
- `complaint` - Negative feedback about existing functionality
|
||||
|
||||
#### Validation Rules
|
||||
- FR-002: `original_text` max length 10,000 characters
|
||||
- FR-011: At least one of (`original_text`, `attachment_count > 0`) must be true
|
||||
- FR-012: Cannot be empty (no text AND no attachments)
|
||||
- FR-027: `submission_timestamp` immutable after creation
|
||||
|
||||
#### State Transitions
|
||||
```
|
||||
submitted → analyzing → analyzed → {reviewed, in_progress, resolved, rejected}
|
||||
↓
|
||||
analysis_failed (terminal state until manual retry)
|
||||
```
|
||||
|
||||
#### File Representation (metadata.yaml)
|
||||
```yaml
|
||||
id: "a3f2c1d5-8b4e-4f1a-9c2d-7e6f5a4b3c2d"
|
||||
product_id: "001-acme-app"
|
||||
original_language: "en"
|
||||
submission_timestamp: "2025-10-15T14:32:10Z"
|
||||
status: "analyzed"
|
||||
category: "bug"
|
||||
attachment_count: 2
|
||||
attachments:
|
||||
- filename: "screenshot.png"
|
||||
size_bytes: 245678
|
||||
mime_type: "image/png"
|
||||
- filename: "error_log.txt"
|
||||
size_bytes: 1234
|
||||
mime_type: "text/plain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Product/Service
|
||||
|
||||
**Description**: Represents a product or service for which feedback can be collected.
|
||||
|
||||
**Storage Location**: `data/products/{product_id}/config.yaml`
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Field | Type | Required | Validation | Description |
|
||||
|-------|------|----------|------------|-------------|
|
||||
| `id` | String | Yes | Unique, URL-safe slug | Product identifier (e.g., "001-acme-app") |
|
||||
| `name` | String | Yes | 1-100 characters | Display name |
|
||||
| `description` | String | No | Max 500 characters | Product description |
|
||||
| `target_language` | String (ISO 639-1) | Yes | 2-char code | Preferred language for AI translations |
|
||||
| `submission_url_slug` | String | Yes | URL-safe, unique | URL path for submission form (e.g., "/submit/acme-app") |
|
||||
| `created_date` | ISO 8601 Date | Yes | Auto-generated | When product was registered |
|
||||
| `status` | Enum | Yes | "active" or "archived" | Current status |
|
||||
| `assigned_owner_ids` | List[String] | No | Must reference existing users | Product owner user IDs |
|
||||
|
||||
#### Validation Rules
|
||||
- FR-046: `id` must be unique across all products
|
||||
- FR-047: `target_language` must be valid ISO 639-1 code
|
||||
- FR-049: `submission_url_slug` must be unique and URL-safe (alphanumeric + hyphens)
|
||||
- FR-053: Cannot accept new feedback if `status` is "archived"
|
||||
|
||||
#### State Transitions
|
||||
```
|
||||
active ⇄ archived (bidirectional, admin only)
|
||||
```
|
||||
|
||||
#### File Representation (config.yaml)
|
||||
```yaml
|
||||
id: "001-acme-app"
|
||||
name: "Acme Mobile App"
|
||||
description: "Customer feedback for Acme's flagship mobile application"
|
||||
target_language: "en"
|
||||
submission_url_slug: "acme-app"
|
||||
created_date: "2025-10-01"
|
||||
status: "active"
|
||||
assigned_owner_ids:
|
||||
- "owner-001"
|
||||
- "owner-002"
|
||||
statistics:
|
||||
total_feedback_count: 127
|
||||
last_submission: "2025-10-15T14:32:10Z"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Analysis Result
|
||||
|
||||
**Description**: Represents the AI-generated analysis of a feedback submission.
|
||||
|
||||
**Storage Location**: `data/products/{product_id}/feedback/{feedback_id}/analysis.md`
|
||||
**Additional Storage**: Original text stored in `content.txt` for reference
|
||||
|
||||
#### Attributes (Markdown Format)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `translated_text` | Markdown | Yes* | Feedback translated to target language (*if different from original) |
|
||||
| `summary` | Markdown | Yes | Concise 2-3 sentence summary in target language |
|
||||
| `detected_category` | Enum | Yes | Category assigned by AI (idea/feature_request/bug/complaint) |
|
||||
| `confidence_score` | Float (0.0-1.0) | Yes | AI confidence in categorization |
|
||||
| `analysis_timestamp` | ISO 8601 DateTime | Yes | When analysis completed |
|
||||
| `model_used` | String | Yes | AI model identifier (e.g., "claude-3-haiku-20240307") |
|
||||
| `error_message` | String | No | Error details if analysis failed |
|
||||
|
||||
#### Validation Rules
|
||||
- FR-014: `summary` should be 2-3 sentences maximum
|
||||
- FR-015: `translated_text` required unless original language = target language
|
||||
- FR-016: Original text preserved in `content.txt` alongside analysis
|
||||
- FR-017: Detected language stored in feedback metadata.yaml `original_language`
|
||||
|
||||
#### File Representation (analysis.md)
|
||||
```markdown
|
||||
# Feedback Analysis
|
||||
|
||||
**Analyzed**: 2025-10-15T14:35:22Z
|
||||
**Model**: claude-3-haiku-20240307
|
||||
**Category**: bug (confidence: 0.92)
|
||||
**Original Language**: de → **Target Language**: en
|
||||
|
||||
## Summary
|
||||
|
||||
User reports that the app crashes when uploading large files. This appears to be a bug affecting the file upload module, preventing users from submitting documents over 5MB.
|
||||
|
||||
## Translation
|
||||
|
||||
**Original (German):**
|
||||
> Die App stürzt ab, wenn ich versuche, große Dateien hochzuladen. Jedes Mal wenn ich eine PDF über 5MB hochlade, friert die App ein und schließt sich.
|
||||
|
||||
**Translated (English):**
|
||||
The app crashes when I try to upload large files. Every time I upload a PDF over 5MB, the app freezes and closes.
|
||||
|
||||
## Attachments
|
||||
|
||||
- screenshot.png (245 KB)
|
||||
- error_log.txt (1 KB)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Attachment
|
||||
|
||||
**Description**: Represents a file (document or image) uploaded with feedback.
|
||||
|
||||
**Storage Location**: `data/products/{product_id}/feedback/{feedback_id}/attachments/{filename}`
|
||||
|
||||
#### Attributes (stored in feedback metadata.yaml)
|
||||
|
||||
| Field | Type | Required | Validation | Description |
|
||||
|-------|------|----------|------------|-------------|
|
||||
| `filename` | String | Yes | Sanitized, max 255 chars | Original filename (sanitized for safety) |
|
||||
| `size_bytes` | Integer | Yes | Max 10,485,760 (10MB) | File size in bytes |
|
||||
| `mime_type` | String | Yes | See allowed types | Validated MIME type |
|
||||
| `upload_timestamp` | ISO 8601 DateTime | Yes | Auto-generated | When file was uploaded |
|
||||
| `virus_scan_status` | Enum | Yes | "clean" or "infected" | ClamAV scan result |
|
||||
|
||||
#### Allowed MIME Types
|
||||
- Documents: `application/pdf`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX), `text/plain`
|
||||
- Images: `image/jpeg`, `image/png`, `image/gif`, `image/webp`
|
||||
|
||||
#### Validation Rules
|
||||
- FR-004: Maximum 3 attachments per feedback
|
||||
- FR-006: Maximum 10MB per file
|
||||
- FR-026: Preserve original filename (sanitized)
|
||||
- FR-059: Sanitize filename to prevent directory traversal
|
||||
- FR-060: Must pass ClamAV virus scan before storage
|
||||
|
||||
#### Security Sanitization
|
||||
- Remove directory traversal patterns: `../`, `..\\`, absolute paths
|
||||
- Replace unsafe characters: `<>:"|?*`
|
||||
- Limit filename length to 255 characters
|
||||
- If duplicate filename, append counter: `file.pdf` → `file_2.pdf`
|
||||
|
||||
---
|
||||
|
||||
### 5. Product Owner
|
||||
|
||||
**Description**: Represents an authenticated user responsible for reviewing feedback for one or more products.
|
||||
|
||||
**Storage Location**: `data/users.yaml`
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Field | Type | Required | Validation | Description |
|
||||
|-------|------|----------|------------|-------------|
|
||||
| `id` | String | Yes | Unique | User identifier (e.g., "owner-001") |
|
||||
| `email` | String | Yes | Valid email, unique | Login email address |
|
||||
| `password_hash` | String (bcrypt) | Yes | bcrypt format | Hashed password (never store plaintext) |
|
||||
| `name` | String | Yes | 1-100 characters | Display name |
|
||||
| `role` | Enum | Yes | "product_owner" | User role (always "product_owner" for this entity) |
|
||||
| `assigned_product_ids` | List[String] | Yes | Must reference existing products | Products this owner can access |
|
||||
| `created_date` | ISO 8601 Date | Yes | Auto-generated | Account creation date |
|
||||
| `last_login` | ISO 8601 DateTime | No | Auto-updated | Last successful login |
|
||||
|
||||
#### Validation Rules
|
||||
- FR-048: Can be assigned to multiple products
|
||||
- FR-056: Must authenticate to access dashboard
|
||||
- FR-063: Passwords hashed with bcrypt (cost factor 12)
|
||||
- FR-033: Can only view feedback for `assigned_product_ids`
|
||||
|
||||
#### File Representation (users.yaml entry)
|
||||
```yaml
|
||||
users:
|
||||
- id: "owner-001"
|
||||
email: "jane.smith@example.com"
|
||||
password_hash: "$2b$12$KIXxBt5H4vE2zT9vN8FqOe9Jx..."
|
||||
name: "Jane Smith"
|
||||
role: "product_owner"
|
||||
assigned_product_ids:
|
||||
- "001-acme-app"
|
||||
- "002-beta-service"
|
||||
created_date: "2025-09-15"
|
||||
last_login: "2025-10-15T09:23:11Z"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Administrator
|
||||
|
||||
**Description**: Represents a privileged user who can register products, assign owners, and manage system configuration.
|
||||
|
||||
**Storage Location**: `data/users.yaml` (same file as Product Owners)
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Field | Type | Required | Validation | Description |
|
||||
|-------|------|----------|------------|-------------|
|
||||
| `id` | String | Yes | Unique | User identifier (e.g., "admin-001") |
|
||||
| `email` | String | Yes | Valid email, unique | Login email address |
|
||||
| `password_hash` | String (bcrypt) | Yes | bcrypt format | Hashed password |
|
||||
| `name` | String | Yes | 1-100 characters | Display name |
|
||||
| `role` | Enum | Yes | "admin" | User role (always "admin" for this entity) |
|
||||
| `assigned_product_ids` | List[String] | Yes | Empty list | Empty = access to all products |
|
||||
| `created_date` | ISO 8601 Date | Yes | Auto-generated | Account creation date |
|
||||
| `last_login` | ISO 8601 DateTime | No | Auto-updated | Last successful login |
|
||||
|
||||
#### Validation Rules
|
||||
- Admin role grants full access regardless of `assigned_product_ids`
|
||||
- FR-045: Can create/modify/archive products
|
||||
- FR-048: Can assign/unassign product owners
|
||||
- Same authentication requirements as Product Owner (FR-056, FR-063)
|
||||
|
||||
#### File Representation (users.yaml entry)
|
||||
```yaml
|
||||
users:
|
||||
- id: "admin-001"
|
||||
email: "admin@reklamator.local"
|
||||
password_hash: "$2b$12$vL3Fx9..."
|
||||
name: "System Administrator"
|
||||
role: "admin"
|
||||
assigned_product_ids: [] # Empty = all access
|
||||
created_date: "2025-09-01"
|
||||
last_login: "2025-10-15T10:45:33Z"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Rules & Invariants
|
||||
|
||||
### Cross-Entity Rules
|
||||
|
||||
1. **Product-Feedback Relationship** (1:N)
|
||||
- Every Feedback must reference exactly one valid Product
|
||||
- Product can have zero or many Feedback items
|
||||
- Archived products cannot receive new feedback (FR-053)
|
||||
|
||||
2. **Feedback-Attachment Relationship** (1:N)
|
||||
- Feedback can have 0-3 Attachments (FR-004, FR-010)
|
||||
- Attachments cannot exist without parent Feedback (cascade delete)
|
||||
|
||||
3. **Feedback-AnalysisResult Relationship** (1:1)
|
||||
- Every analyzed Feedback has exactly one AnalysisResult
|
||||
- AnalysisResult created asynchronously after Feedback submission
|
||||
- Original content preserved even if analysis fails (FR-020)
|
||||
|
||||
4. **Product-Owner Relationship** (N:M)
|
||||
- Product can have 1 or more assigned Product Owners (FR-048)
|
||||
- Product Owner can be assigned to multiple Products
|
||||
- Admin users bypass assignment logic (implicit access to all)
|
||||
|
||||
5. **Anonymity Constraint** (Global)
|
||||
- No IP addresses stored in Feedback metadata (FR-055, SC-010)
|
||||
- No session tracking for anonymous submissions
|
||||
- Rate limiting uses IP for abuse prevention only (not persisted)
|
||||
|
||||
### Deletion Rules
|
||||
|
||||
- **Feedback Deletion**: Deletes metadata.yaml, content.txt, analysis.md, and all attachments/
|
||||
- **Product Archival**: Sets status to "archived", preserves all feedback (FR-052)
|
||||
- **Product Owner Removal**: Unassigns from products, does not delete feedback
|
||||
- **Cascade Protection**: Cannot delete Product with active feedback (archive instead)
|
||||
|
||||
---
|
||||
|
||||
## Storage Implementation Notes
|
||||
|
||||
### Directory Structure Example
|
||||
|
||||
```
|
||||
data/
|
||||
├── users.yaml # All users (admins + owners)
|
||||
└── products/
|
||||
├── 001-acme-app/
|
||||
│ ├── config.yaml # Product metadata
|
||||
│ └── feedback/
|
||||
│ ├── a3f2c1d5-8b4e-.../
|
||||
│ │ ├── metadata.yaml # Feedback + attachment metadata
|
||||
│ │ ├── content.txt # Original feedback text
|
||||
│ │ ├── analysis.md # AI analysis report
|
||||
│ │ └── attachments/
|
||||
│ │ ├── screenshot.png
|
||||
│ │ └── error_log.txt
|
||||
│ └── b7e1f3d2-4a9c-.../
|
||||
│ ├── metadata.yaml
|
||||
│ ├── content.txt
|
||||
│ └── analysis.md # No attachments/ for this one
|
||||
└── 002-beta-service/
|
||||
├── config.yaml
|
||||
└── feedback/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### File Format Standards
|
||||
|
||||
- **YAML**: UTF-8 encoding, 2-space indentation, explicit type declarations
|
||||
- **Markdown**: CommonMark specification, UTF-8 encoding
|
||||
- **Text Files**: UTF-8 encoding with BOM handling
|
||||
|
||||
### Indexing Strategy (Performance)
|
||||
|
||||
For dashboard performance (SC-008, SC-014):
|
||||
- Cache product feedback counts in `config.yaml` statistics
|
||||
- Implement pagination (50 items per page)
|
||||
- Load metadata.yaml only, defer analysis.md loading until detail view
|
||||
- File modification times used for sorting (newest first per FR-041)
|
||||
|
||||
---
|
||||
|
||||
## Validation Summary by Functional Requirement
|
||||
|
||||
| FR | Validation Location | Rule |
|
||||
|----|---------------------|------|
|
||||
| FR-002 | Feedback.original_text | Max 10,000 characters |
|
||||
| FR-004 | Feedback.attachment_count | 0-3 attachments |
|
||||
| FR-006 | Attachment.size_bytes | Max 10MB per file |
|
||||
| FR-011 | Feedback validation | At least text OR files required |
|
||||
| FR-012 | Feedback validation | Cannot be completely empty |
|
||||
| FR-046 | Product.id | Must be unique |
|
||||
| FR-048 | Product.assigned_owner_ids | 1+ owners required |
|
||||
| FR-053 | Product status check | Reject if archived |
|
||||
| FR-063 | User.password_hash | bcrypt with cost 12 |
|
||||
|
||||
---
|
||||
|
||||
**Next Steps**: Define API contracts in `/contracts/` directory
|
||||
@@ -0,0 +1,190 @@
|
||||
# Implementation Plan: Anonymous Feedback Platform (Reklamator)
|
||||
|
||||
**Branch**: `001-build-an-application` | **Date**: 2025-10-15 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/001-build-an-application/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
Build a minimal web application using Flask that enables anonymous feedback submission with AI-powered analysis and translation. The system uses a file-based storage approach with folders for each submission, YAML metadata files, and markdown-formatted AI analysis reports. Product owners access analyzed feedback through an authenticated web dashboard. Design prioritizes simplicity and functionality over aesthetics - plain HTML without CSS frameworks or JavaScript libraries.
|
||||
|
||||
**POC Scope Update (2025-10-17)**: User Story 4 (Product/Service Registration and Management) has been **deferred** for the proof-of-concept. Products and users will be managed manually via YAML configuration files (`data/products/{id}/config.yaml` and `data/users.yaml`). This decision removes admin UI complexity while maintaining full functionality for POC validation. User Stories 1-3 (Anonymous Submission, AI Analysis, Dashboard) remain in scope and are **IMPLEMENTED**.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.11+
|
||||
**Primary Dependencies**: Flask (web framework), no CSS frameworks, no JavaScript libraries
|
||||
**Storage**: File-based - folders per feedback item with YAML metadata and markdown reports
|
||||
**Testing**: pytest (contract and integration tests prioritized per constitution)
|
||||
**Target Platform**: Linux server (web application)
|
||||
**Project Type**: web (backend + frontend, but minimal frontend without frameworks)
|
||||
**AI Integration**: NEEDS CLARIFICATION - Claude API or pluggable AI provider interface
|
||||
**Performance Goals**: Handle 100 concurrent submissions, <3s dashboard load for 1000 items
|
||||
**Constraints**: <30s AI analysis time for 95% of submissions, complete anonymity (no IP/session tracking)
|
||||
**Scale/Scope**: MVP supports 100 products, 10,000 feedback items per product, 50+ languages
|
||||
**File Upload**: NEEDS CLARIFICATION - malware scanning approach, storage location strategy
|
||||
**Authentication**: NEEDS CLARIFICATION - session management approach for product owners
|
||||
**Rate Limiting**: NEEDS CLARIFICATION - implementation strategy for submission abuse prevention
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
### ✅ I. Specification-First Development
|
||||
**Status**: PASS
|
||||
Complete specification exists at `specs/001-build-an-application/spec.md` with prioritized user stories (P1-P4), 64 functional requirements with unique IDs (FR-001 to FR-064), measurable success criteria (SC-001 to SC-014), and comprehensive edge cases. All user stories are independently testable.
|
||||
|
||||
### ✅ II. Test-First Discipline
|
||||
**Status**: PASS (Will be enforced during implementation)
|
||||
Plan includes pytest as testing framework. Implementation phase will follow mandatory workflow: write tests → verify failures → implement code → refactor. Contract and integration tests prioritized per constitution.
|
||||
|
||||
### ✅ III. Independent User Stories
|
||||
**Status**: PASS
|
||||
Four user stories explicitly prioritized (P1: Anonymous Submission, P2: AI Analysis, P3: Dashboard, P4: Product Management). Each story is independently deliverable and testable. P1 can function standalone, P2 depends only on P1, P3 on P1+P2, P4 adds multi-product support.
|
||||
|
||||
### ✅ IV. Simplicity & Justification
|
||||
**Status**: PASS
|
||||
Design explicitly minimizes complexity: plain HTML without CSS frameworks, no JavaScript libraries, file-based storage (no database), Flask for web framework (minimal dependencies). User input emphasizes "designed as simple as possible" and "functionality over design."
|
||||
|
||||
**Potential Complexity Point**: File-based storage vs. database
|
||||
- **Decision**: File-based storage with folder-per-feedback structure
|
||||
- **Rationale**: Simpler deployment, no database setup/maintenance, natural fit for storing files+metadata together, sufficient for MVP scale (100 products × 10k items)
|
||||
- **Alternative Rejected**: PostgreSQL/SQLite - adds operational complexity, requires schema migrations, doesn't simplify file attachment handling
|
||||
|
||||
### ✅ V. Documentation as Code
|
||||
**Status**: PASS
|
||||
Specification-driven workflow with all documentation in version control under `/specs/001-build-an-application/`. This plan will generate: research.md, data-model.md, contracts/, quickstart.md per constitution requirements.
|
||||
|
||||
### Gate Result: ✅ PASS - Proceed to Phase 0 Research
|
||||
|
||||
No constitutional violations detected. Complexity Tracking table remains empty.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```
|
||||
reklamator/
|
||||
├── app/
|
||||
│ ├── __init__.py # Flask app factory
|
||||
│ ├── routes/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── submission.py # Anonymous feedback submission endpoints
|
||||
│ │ ├── dashboard.py # Product owner dashboard endpoints
|
||||
│ │ └── admin.py # Product/owner management endpoints
|
||||
│ ├── services/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── feedback_storage.py # File-based storage operations
|
||||
│ │ ├── ai_analyzer.py # AI analysis/translation interface
|
||||
│ │ └── auth.py # Session management
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── feedback.py # Feedback domain model
|
||||
│ │ ├── product.py # Product domain model
|
||||
│ │ └── user.py # Product owner/admin model
|
||||
│ ├── templates/ # Plain HTML templates (Jinja2)
|
||||
│ │ ├── submission_form.html
|
||||
│ │ ├── dashboard.html
|
||||
│ │ ├── feedback_detail.html
|
||||
│ │ └── admin_products.html
|
||||
│ └── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── file_validator.py # File upload validation
|
||||
│ └── rate_limiter.py # Submission rate limiting
|
||||
│
|
||||
├── data/ # File-based storage root
|
||||
│ └── products/
|
||||
│ └── {product-id}/
|
||||
│ └── feedback/
|
||||
│ └── {feedback-id}/
|
||||
│ ├── metadata.yaml
|
||||
│ ├── analysis.md
|
||||
│ └── attachments/
|
||||
│
|
||||
├── tests/
|
||||
│ ├── contract/ # API contract tests
|
||||
│ │ ├── test_submission_api.py
|
||||
│ │ ├── test_dashboard_api.py
|
||||
│ │ └── test_admin_api.py
|
||||
│ ├── integration/ # User journey tests
|
||||
│ │ ├── test_feedback_submission_flow.py
|
||||
│ │ ├── test_ai_analysis_flow.py
|
||||
│ │ └── test_dashboard_access_flow.py
|
||||
│ └── unit/ # Optional unit tests for complex logic
|
||||
│ ├── test_feedback_storage.py
|
||||
│ └── test_file_validator.py
|
||||
│
|
||||
├── config/
|
||||
│ ├── development.py
|
||||
│ ├── production.py
|
||||
│ └── testing.py
|
||||
│
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
└── run.py # Application entry point
|
||||
```
|
||||
|
||||
**Structure Decision**: Selected web application structure (Option 2 variant) with backend-focused layout since frontend is minimal (plain HTML templates). Flask follows a single-project structure but organized by layers (routes/services/models). The `data/` directory implements the file-based storage requirement with nested folders per product and feedback item. Templates directory contains plain HTML served by Flask without separate frontend build process.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*Fill ONLY if Constitution Check has violations that must be justified*
|
||||
|
||||
No violations detected. Table remains empty.
|
||||
|
||||
---
|
||||
|
||||
## Post-Design Constitution Re-Check
|
||||
|
||||
*Re-evaluated after Phase 1 design completion*
|
||||
|
||||
### ✅ I. Specification-First Development
|
||||
**Status**: PASS (unchanged)
|
||||
Design artifacts (research.md, data-model.md, contracts/, quickstart.md) generated from specification. No implementation code written yet.
|
||||
|
||||
### ✅ II. Test-First Discipline
|
||||
**Status**: PASS (unchanged)
|
||||
API contracts define testable behaviors. Contract tests can be written before implementation. Quickstart guide includes test-first workflow examples.
|
||||
|
||||
### ✅ III. Independent User Stories
|
||||
**Status**: PASS (unchanged)
|
||||
Data model and API contracts support independent implementation of P1→P2→P3→P4 stories. Each has clear endpoints and data structures.
|
||||
|
||||
### ✅ IV. Simplicity & Justification
|
||||
**Status**: PASS (confirmed post-design)
|
||||
- File-based storage design confirmed (YAML + Markdown)
|
||||
- No database complexity introduced
|
||||
- Minimal dependencies: Flask + 7 small extensions
|
||||
- Plain HTML templates (no CSS frameworks, no JavaScript)
|
||||
- Direct file I/O (no ORM or abstraction layers)
|
||||
- Single-project structure (no microservices)
|
||||
|
||||
**Design Review**: All research decisions favor simplicity. No new complexity introduced during Phase 1.
|
||||
|
||||
### ✅ V. Documentation as Code
|
||||
**Status**: PASS (enhanced)
|
||||
Generated artifacts:
|
||||
- ✅ research.md (7 decision records)
|
||||
- ✅ data-model.md (6 entities fully specified)
|
||||
- ✅ contracts/ (3 web route contracts: submission, dashboard, admin)
|
||||
- ✅ quickstart.md (developer onboarding guide)
|
||||
- ✅ CLAUDE.md (agent context updated)
|
||||
|
||||
All documentation version-controlled, linked to spec.md.
|
||||
|
||||
### Final Gate Result: ✅ PASS - Ready for Phase 2 (Task Generation)
|
||||
|
||||
No constitutional violations introduced during design phase. Proceed to `/speckit.tasks` command.
|
||||
@@ -0,0 +1,469 @@
|
||||
# Quickstart Guide: Reklamator Development
|
||||
|
||||
**Branch**: `001-build-an-application` | **Date**: 2025-10-15
|
||||
|
||||
This guide helps developers set up the Reklamator development environment and understand the project structure.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11 or higher
|
||||
- ClamAV daemon (`clamd`) for malware scanning
|
||||
- Git
|
||||
- Virtual environment tool (venv)
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd reklamator
|
||||
git checkout 001-build-an-application
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
**Expected Core Dependencies**:
|
||||
- Flask 3.0+
|
||||
- Flask-Login (session management)
|
||||
- Flask-Limiter (rate limiting)
|
||||
- Flask-WTF (CSRF protection)
|
||||
- anthropic (Claude API client)
|
||||
- clamd (ClamAV integration)
|
||||
- bcrypt (password hashing)
|
||||
- PyYAML (configuration files)
|
||||
- pytest, pytest-flask (testing)
|
||||
|
||||
### 4. Install and Configure ClamAV
|
||||
|
||||
**Ubuntu/Debian**:
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install clamav clamav-daemon
|
||||
sudo systemctl start clamav-daemon
|
||||
sudo systemctl enable clamav-daemon
|
||||
```
|
||||
|
||||
**macOS**:
|
||||
```bash
|
||||
brew install clamav
|
||||
brew services start clamav
|
||||
```
|
||||
|
||||
**Verify ClamAV is running**:
|
||||
```bash
|
||||
clamdscan --version
|
||||
```
|
||||
|
||||
### 5. Set Up Environment Variables
|
||||
|
||||
Create `.env` file in project root:
|
||||
|
||||
```bash
|
||||
# Flask Configuration
|
||||
FLASK_APP=run.py
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY=your-claude-api-key-here
|
||||
|
||||
# ClamAV
|
||||
CLAMD_SOCKET=/var/run/clamav/clamd.ctl # Adjust path for your system
|
||||
|
||||
# File Storage
|
||||
DATA_DIR=./data
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_PER_HOUR=10
|
||||
```
|
||||
|
||||
**Get Claude API Key**:
|
||||
1. Sign up at https://console.anthropic.com/
|
||||
2. Create an API key
|
||||
3. Add to `.env` file
|
||||
|
||||
### 6. Initialize Data Directory
|
||||
|
||||
```bash
|
||||
mkdir -p data/products
|
||||
```
|
||||
|
||||
### 7. Create Initial Admin User
|
||||
|
||||
Create `data/users.yaml`:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
- id: "admin-001"
|
||||
email: "admin@localhost"
|
||||
password_hash: "$2b$12$KIXxBt5H4vE2zT9vN8FqOe9JxwLxPqz0q5kYv2Z3j4RQvN8FqOe9J" # Password: "admin123"
|
||||
name: "Admin User"
|
||||
role: "admin"
|
||||
assigned_product_ids: []
|
||||
created_date: "2025-10-15"
|
||||
last_login: null
|
||||
```
|
||||
|
||||
**Security Note**: Change the password immediately after first login!
|
||||
|
||||
To generate a new password hash:
|
||||
```python
|
||||
import bcrypt
|
||||
password = "your-password-here"
|
||||
hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))
|
||||
print(hash.decode('utf-8'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
Application will be available at: http://localhost:5000
|
||||
|
||||
### Production Server (Gunicorn)
|
||||
|
||||
```bash
|
||||
gunicorn -w 4 -b 0.0.0.0:8000 "app:create_app()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure Overview
|
||||
|
||||
```
|
||||
reklamator/
|
||||
├── app/ # Application code
|
||||
│ ├── __init__.py # Flask app factory
|
||||
│ ├── routes/ # HTTP endpoints
|
||||
│ │ ├── submission.py # Anonymous feedback submission
|
||||
│ │ ├── dashboard.py # Product owner dashboard
|
||||
│ │ └── admin.py # Admin interface
|
||||
│ ├── services/ # Business logic
|
||||
│ │ ├── feedback_storage.py # File-based storage operations
|
||||
│ │ ├── ai_analyzer.py # AI analysis/translation
|
||||
│ │ └── auth.py # Authentication
|
||||
│ ├── models/ # Domain models
|
||||
│ │ ├── feedback.py # Feedback entity
|
||||
│ │ ├── product.py # Product entity
|
||||
│ │ └── user.py # User entity
|
||||
│ ├── templates/ # HTML templates (Jinja2)
|
||||
│ └── utils/ # Utilities
|
||||
│ ├── file_validator.py # File upload validation
|
||||
│ └── rate_limiter.py # Rate limiting
|
||||
│
|
||||
├── data/ # File-based storage
|
||||
│ ├── users.yaml # User accounts
|
||||
│ └── products/ # Product-specific data
|
||||
│ └── {product-id}/
|
||||
│ ├── config.yaml # Product metadata
|
||||
│ └── feedback/
|
||||
│ └── {feedback-id}/
|
||||
│ ├── metadata.yaml
|
||||
│ ├── content.txt
|
||||
│ ├── analysis.md
|
||||
│ └── attachments/
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
│ ├── contract/ # API contract tests
|
||||
│ ├── integration/ # User journey tests
|
||||
│ └── unit/ # Unit tests
|
||||
│
|
||||
├── config/ # Configuration files
|
||||
│ ├── development.py
|
||||
│ ├── production.py
|
||||
│ └── testing.py
|
||||
│
|
||||
├── specs/ # Feature specifications (this directory)
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
├── .env # Environment variables (not in git)
|
||||
└── run.py # Application entry point
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Creating a Test Product
|
||||
|
||||
1. Log in as admin: http://localhost:5000/login
|
||||
- Email: `admin@localhost`
|
||||
- Password: `admin123`
|
||||
|
||||
2. Navigate to: http://localhost:5000/admin/products
|
||||
|
||||
3. Click "Create New Product" and fill in:
|
||||
- ID: `001-test-product`
|
||||
- Name: `Test Product`
|
||||
- Target Language: `en`
|
||||
- Submission URL Slug: `test-product`
|
||||
- Assign yourself as product owner
|
||||
|
||||
4. Access submission form: http://localhost:5000/submit/test-product
|
||||
|
||||
### Submitting Test Feedback
|
||||
|
||||
1. Visit: http://localhost:5000/submit/test-product
|
||||
2. Enter feedback text
|
||||
3. Optionally attach files (max 3, max 10MB each)
|
||||
4. Submit
|
||||
|
||||
Feedback will be processed asynchronously. Check the dashboard to view analysis results.
|
||||
|
||||
### Viewing Feedback in Dashboard
|
||||
|
||||
1. Log in: http://localhost:5000/login
|
||||
2. Dashboard: http://localhost:5000/dashboard
|
||||
3. Click on feedback item to view details
|
||||
|
||||
### Running Tests
|
||||
|
||||
**All tests**:
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
**Contract tests only**:
|
||||
```bash
|
||||
pytest tests/contract/
|
||||
```
|
||||
|
||||
**Integration tests only**:
|
||||
```bash
|
||||
pytest tests/integration/
|
||||
```
|
||||
|
||||
**With coverage**:
|
||||
```bash
|
||||
pytest --cov=app --cov-report=html
|
||||
```
|
||||
|
||||
**Test-first workflow** (per constitution):
|
||||
1. Write test for new feature (should fail)
|
||||
2. Run test to verify failure
|
||||
3. Implement feature
|
||||
4. Run test to verify success
|
||||
5. Refactor if needed
|
||||
|
||||
---
|
||||
|
||||
## Web Routes Reference
|
||||
|
||||
### Anonymous Submission
|
||||
- `GET /submit/{product_slug}` - Submission form
|
||||
- `POST /submit/{product_slug}` - Submit feedback
|
||||
|
||||
### Authentication
|
||||
- `GET /login` - Login form
|
||||
- `POST /login` - Authenticate
|
||||
- `GET /logout` - Log out
|
||||
|
||||
### Dashboard (Product Owners)
|
||||
- `GET /dashboard` - Feedback list (with filters)
|
||||
- `GET /feedback/{feedback_id}` - Feedback detail
|
||||
- `POST /feedback/{feedback_id}/status` - Update status
|
||||
- `GET /feedback/{feedback_id}/attachment/{filename}` - Download attachment
|
||||
|
||||
### Admin
|
||||
- `GET /admin/products` - List products
|
||||
- `GET /admin/products/new` - Create product form
|
||||
- `POST /admin/products` - Create product
|
||||
- `GET /admin/products/{id}/edit` - Edit product form
|
||||
- `POST /admin/products/{id}` - Update product
|
||||
- `POST /admin/products/{id}/archive` - Archive product
|
||||
- `GET /admin/users` - List users
|
||||
- `POST /admin/users` - Create user
|
||||
- `POST /admin/users/{id}` - Update user
|
||||
|
||||
Full API contracts: See `/specs/001-build-an-application/contracts/`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Development Configuration (`config/development.py`)
|
||||
|
||||
```python
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
|
||||
DATA_DIR = os.environ.get('DATA_DIR', './data')
|
||||
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY')
|
||||
CLAMD_SOCKET = os.environ.get('CLAMD_SOCKET', '/var/run/clamav/clamd.ctl')
|
||||
MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB max upload
|
||||
RATE_LIMIT_ENABLED = True
|
||||
RATE_LIMIT_PER_HOUR = 10
|
||||
```
|
||||
|
||||
### Production Configuration (`config/production.py`)
|
||||
|
||||
```python
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') # Required, no default
|
||||
DATA_DIR = os.environ.get('DATA_DIR', '/var/lib/reklamator/data')
|
||||
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY') # Required
|
||||
CLAMD_SOCKET = os.environ.get('CLAMD_SOCKET', '/var/run/clamav/clamd.ctl')
|
||||
MAX_CONTENT_LENGTH = 10 * 1024 * 1024
|
||||
RATE_LIMIT_ENABLED = True
|
||||
RATE_LIMIT_PER_HOUR = 10
|
||||
SESSION_COOKIE_SECURE = True # HTTPS only
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### ClamAV Connection Error
|
||||
|
||||
**Error**: `pyclamd.ConnectionError: Could not connect to clamd`
|
||||
|
||||
**Solution**:
|
||||
1. Verify ClamAV is running: `sudo systemctl status clamav-daemon`
|
||||
2. Check socket path: `ls /var/run/clamav/clamd.ctl`
|
||||
3. Update `CLAMD_SOCKET` in `.env` if needed
|
||||
4. Restart ClamAV: `sudo systemctl restart clamav-daemon`
|
||||
|
||||
### Claude API Error
|
||||
|
||||
**Error**: `anthropic.APIError: Invalid API key`
|
||||
|
||||
**Solution**:
|
||||
1. Verify API key in `.env` file
|
||||
2. Check key is active at https://console.anthropic.com/
|
||||
3. Ensure no extra whitespace in key
|
||||
|
||||
### File Upload Fails
|
||||
|
||||
**Error**: `413 Payload Too Large`
|
||||
|
||||
**Solution**:
|
||||
- Check file size (max 10MB per file)
|
||||
- Check total payload size (3 files + form data)
|
||||
- Verify `MAX_CONTENT_LENGTH` in config
|
||||
|
||||
**Error**: `Unsupported file type`
|
||||
|
||||
**Solution**:
|
||||
- Verify file extension: `.pdf`, `.docx`, `.txt`, `.jpg`, `.png`, `.gif`, `.webp`
|
||||
- Check MIME type matches extension
|
||||
|
||||
### Rate Limit Exceeded
|
||||
|
||||
**Error**: `429 Too Many Requests`
|
||||
|
||||
**Solution**:
|
||||
- Wait 1 hour before retrying
|
||||
- For development, disable rate limiting: `RATE_LIMIT_ENABLED=false` in `.env`
|
||||
- Or increase limit: `RATE_LIMIT_PER_HOUR=100`
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Test-First Discipline (Constitutional Requirement)
|
||||
|
||||
1. **Before implementing any feature**:
|
||||
- Write contract/integration test
|
||||
- Run test to verify it fails
|
||||
- Implement feature
|
||||
- Run test to verify success
|
||||
|
||||
2. **Test organization**:
|
||||
- Contract tests: Test API endpoints (HTTP requests/responses)
|
||||
- Integration tests: Test user journeys (multi-step workflows)
|
||||
- Unit tests: Test complex business logic in isolation
|
||||
|
||||
3. **Example test-first workflow**:
|
||||
|
||||
```python
|
||||
# Step 1: Write test (tests/contract/test_submission_api.py)
|
||||
def test_submit_feedback_with_text_only(client):
|
||||
response = client.post('/submit/test-product', data={
|
||||
'feedback_text': 'This is test feedback'
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert b'Thank You!' in response.data
|
||||
|
||||
# Step 2: Run test (should FAIL - endpoint not implemented)
|
||||
# pytest tests/contract/test_submission_api.py::test_submit_feedback_with_text_only
|
||||
|
||||
# Step 3: Implement feature (app/routes/submission.py)
|
||||
@bp.route('/submit/<product_slug>', methods=['POST'])
|
||||
def submit_feedback(product_slug):
|
||||
# Implementation here
|
||||
pass
|
||||
|
||||
# Step 4: Run test again (should PASS)
|
||||
# pytest tests/contract/test_submission_api.py::test_submit_feedback_with_text_only
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- Follow PEP 8
|
||||
- Use type hints where helpful
|
||||
- Keep functions small and focused
|
||||
- Prefer clear names over comments
|
||||
- Run linting: `flake8 app/`
|
||||
- Run formatting: `black app/`
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- Feature branch: `001-build-an-application` (already created)
|
||||
- Commit messages: Descriptive, imperative mood
|
||||
- Test before committing
|
||||
- Regular integration to main branch
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Set up environment** following steps above
|
||||
2. **Run tests** to verify setup: `pytest`
|
||||
3. **Start development server**: `python run.py`
|
||||
4. **Create test product** via admin interface
|
||||
5. **Submit test feedback** via submission form
|
||||
6. **Review implementation plan**: `/specs/001-build-an-application/plan.md`
|
||||
7. **Begin task implementation**: Wait for `/specs/001-build-an-application/tasks.md` (generated by `/speckit.tasks`)
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Feature Specification**: `/specs/001-build-an-application/spec.md`
|
||||
- **Implementation Plan**: `/specs/001-build-an-application/plan.md`
|
||||
- **Research**: `/specs/001-build-an-application/research.md`
|
||||
- **Data Model**: `/specs/001-build-an-application/data-model.md`
|
||||
- **API Contracts**: `/specs/001-build-an-application/contracts/`
|
||||
- **Flask Documentation**: https://flask.palletsprojects.com/
|
||||
- **Claude API Documentation**: https://docs.anthropic.com/
|
||||
- **Pytest Documentation**: https://docs.pytest.org/
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Refer to the specification documents or implementation plan for detailed requirements and design decisions.
|
||||
@@ -0,0 +1,366 @@
|
||||
# Research: Anonymous Feedback Platform (Reklamator)
|
||||
|
||||
**Branch**: `001-build-an-application` | **Date**: 2025-10-15
|
||||
|
||||
This document resolves all NEEDS CLARIFICATION items identified in the Technical Context section of plan.md.
|
||||
|
||||
## 1. AI Integration Approach
|
||||
|
||||
### Decision: Pluggable AI provider interface with Claude as default
|
||||
|
||||
**Rationale**:
|
||||
- User input specifies "we use claude but it can be any other service as well"
|
||||
- Designing for extensibility aligns with good architectural practice
|
||||
- Enables future migration to different AI providers without major refactoring
|
||||
|
||||
**Implementation Approach**:
|
||||
- Abstract base class `AIAnalyzer` defining interface: `analyze_feedback(text: str, target_lang: str) -> AnalysisResult`
|
||||
- Concrete implementation `ClaudeAnalyzer` using Anthropic API
|
||||
- Configuration-driven provider selection
|
||||
- API key management via environment variables
|
||||
|
||||
**Alternatives Considered**:
|
||||
- **Hard-coded Claude API integration**: Simpler initially but violates user requirement for provider flexibility
|
||||
- **LangChain framework**: Adds significant dependency weight for simple translation/categorization task
|
||||
- **Multiple provider implementations from start**: Premature complexity - implement Claude first, abstract as needed
|
||||
|
||||
**Claude API Specifics**:
|
||||
- Use `anthropic` Python SDK
|
||||
- Model: `claude-3-haiku-20240307` for cost-effective analysis (fast, sufficient for categorization/translation)
|
||||
- Prompt design: Single API call with structured output for category, summary, translation
|
||||
- Error handling: Retry logic for transient failures, graceful degradation for persistent errors
|
||||
|
||||
**Research References**:
|
||||
- Anthropic API Documentation: https://docs.anthropic.com/
|
||||
- Python SDK: https://github.com/anthropics/anthropic-sdk-python
|
||||
|
||||
---
|
||||
|
||||
## 2. File Upload - Malware Scanning Approach
|
||||
|
||||
### Decision: ClamAV integration via clamd for virus scanning
|
||||
|
||||
**Rationale**:
|
||||
- FR-060 requires malware scanning before storage
|
||||
- ClamAV is open-source, widely used, actively maintained
|
||||
- `clamd` provides Python bindings for integration
|
||||
- Suitable for on-premise deployment matching file-based storage philosophy
|
||||
|
||||
**Implementation Approach**:
|
||||
- Install ClamAV daemon (`clamd`) as system service
|
||||
- Use `clamd` Python library for scanning uploaded files
|
||||
- Scan files synchronously during upload before writing to disk
|
||||
- Reject files that fail virus scan with clear error message
|
||||
- Log scanning failures for security monitoring
|
||||
|
||||
**Configuration**:
|
||||
- Maximum file size: 10MB per file (FR-006)
|
||||
- Allowed extensions: `.pdf`, `.docx`, `.txt`, `.jpg`, `.png`, `.gif`, `.webp`
|
||||
- MIME type validation in addition to extension checking
|
||||
- Temporary upload storage cleaned after scan (pass or fail)
|
||||
|
||||
**Alternatives Considered**:
|
||||
- **Cloud-based scanning (VirusTotal API)**: Violates anonymity requirement (uploads data externally), adds latency
|
||||
- **No scanning**: Violates FR-060 security requirement
|
||||
- **Manual review**: Not scalable, delays feedback processing
|
||||
- **Python-based scanning (yara-python)**: More complex to configure, less comprehensive than ClamAV
|
||||
|
||||
**Dependencies**:
|
||||
- `clamd` Python library
|
||||
- ClamAV daemon installed on server
|
||||
|
||||
---
|
||||
|
||||
## 3. File Upload - Storage Location Strategy
|
||||
|
||||
### Decision: Local filesystem storage in `data/products/{product-id}/feedback/{feedback-id}/attachments/`
|
||||
|
||||
**Rationale**:
|
||||
- Aligns with file-based storage architecture (no database)
|
||||
- User input specifies "folders foreach user input" and "files in a folder"
|
||||
- Simple to implement, backup, and inspect
|
||||
- No additional service dependencies
|
||||
- Sufficient for MVP scale (100 products × 10k items × 3 files × 10MB = ~30TB worst case)
|
||||
|
||||
**Directory Structure**:
|
||||
```
|
||||
data/
|
||||
└── products/
|
||||
└── {product-id}/ # e.g., "001-acme-app"
|
||||
├── config.yaml # Product metadata (name, target language, owners)
|
||||
└── feedback/
|
||||
└── {feedback-id}/ # UUID v4, e.g., "a3f2c1d5-..."
|
||||
├── metadata.yaml # Feedback metadata (timestamp, status, category, etc.)
|
||||
├── content.txt # Original feedback text
|
||||
├── analysis.md # AI-generated analysis report
|
||||
└── attachments/
|
||||
├── original_filename_1.pdf
|
||||
├── original_filename_2.png
|
||||
└── original_filename_3.jpg
|
||||
```
|
||||
|
||||
**File Naming**:
|
||||
- Preserve original filenames to maintain user context
|
||||
- Sanitize filenames to prevent directory traversal (strip `../`, absolute paths, etc.)
|
||||
- Handle duplicate filenames by appending counter if needed
|
||||
|
||||
**Alternatives Considered**:
|
||||
- **Cloud storage (S3/GCS)**: Adds external dependency, cost, complexity; overkill for MVP
|
||||
- **Flat directory per product**: Poor scalability, difficult to organize metadata
|
||||
- **Database with BLOB storage**: Contradicts file-based storage decision, adds DB complexity
|
||||
- **Content-addressed storage (hash-based filenames)**: Loses original filename context, complicates presentation
|
||||
|
||||
**Backup Strategy** (out of scope for MVP but noted):
|
||||
- Simple filesystem backup via rsync/tar sufficient
|
||||
- Can upgrade to cloud sync if needed later
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentication - Session Management Approach
|
||||
|
||||
### Decision: Flask-Login with server-side sessions for product owners/admins
|
||||
|
||||
**Rationale**:
|
||||
- Flask-Login is standard, well-tested session management for Flask
|
||||
- Server-side sessions prevent token tampering
|
||||
- Simple username/password authentication sufficient for MVP
|
||||
- FR-056, FR-063 require authentication and secure password storage
|
||||
|
||||
**Implementation Approach**:
|
||||
- Use `Flask-Login` extension for session management
|
||||
- Store user credentials in simple YAML file (products/users.yaml) for MVP consistency with file-based approach
|
||||
- Hash passwords with `bcrypt` (FR-063)
|
||||
- Session cookies: `HttpOnly`, `Secure` (HTTPS only), `SameSite=Lax`
|
||||
- Session timeout: 24 hours of inactivity
|
||||
|
||||
**User Model**:
|
||||
```yaml
|
||||
users:
|
||||
- id: "admin-001"
|
||||
email: "admin@example.com"
|
||||
password_hash: "$2b$12$..."
|
||||
role: "admin"
|
||||
assigned_products: [] # Empty = all products access
|
||||
|
||||
- id: "owner-001"
|
||||
email: "owner@example.com"
|
||||
password_hash: "$2b$12$..."
|
||||
role: "product_owner"
|
||||
assigned_products: ["001-acme-app", "002-beta-service"]
|
||||
```
|
||||
|
||||
**Access Control**:
|
||||
- Admins: Full access to all products, can manage products/owners
|
||||
- Product Owners: Read-only access to assigned products only (FR-033)
|
||||
- Anonymous users: Submission form access only (no authentication)
|
||||
|
||||
**Alternatives Considered**:
|
||||
- **JWT tokens**: More complex, unnecessary for server-rendered HTML application
|
||||
- **OAuth/SAML**: Over-engineered for MVP, adds external identity provider dependency
|
||||
- **Database-backed sessions**: Contradicts file-based architecture
|
||||
- **No authentication**: Violates FR-056 requirement
|
||||
|
||||
**Dependencies**:
|
||||
- `Flask-Login` extension
|
||||
- `bcrypt` for password hashing
|
||||
|
||||
---
|
||||
|
||||
## 5. Rate Limiting - Implementation Strategy
|
||||
|
||||
### Decision: Flask-Limiter with IP-based rate limiting for submission endpoint
|
||||
|
||||
**Rationale**:
|
||||
- FR-061 requires rate limiting (suggested: 10 submissions/hour/IP)
|
||||
- Flask-Limiter is standard, well-maintained Flask extension
|
||||
- IP-based limiting suitable for anonymous submissions
|
||||
- In-memory storage sufficient for MVP (single server deployment)
|
||||
|
||||
**Implementation Approach**:
|
||||
- Use `Flask-Limiter` extension
|
||||
- Apply rate limit decorator to submission route: `@limiter.limit("10 per hour")`
|
||||
- Storage backend: In-memory (default) for MVP
|
||||
- Return HTTP 429 Too Many Requests with clear error message
|
||||
- Exempt authenticated admin users from rate limits (for testing)
|
||||
|
||||
**Rate Limit Configuration**:
|
||||
- Anonymous submission: 10 requests per hour per IP address
|
||||
- Dashboard/admin routes: No rate limiting (authenticated users only)
|
||||
- Rate limit headers included in response: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
||||
|
||||
**Considerations**:
|
||||
- IP-based limiting can be circumvented via VPN/proxy but sufficient deterrent for casual abuse
|
||||
- Behind proxy/load balancer: Configure Flask-Limiter to read `X-Forwarded-For` header
|
||||
- Note: FR-055 requires no fingerprinting for identification - rate limiting is for abuse prevention only, not user tracking
|
||||
|
||||
**Alternatives Considered**:
|
||||
- **CAPTCHA (hCaptcha/reCAPTCHA)**: Adds friction to user experience, contradicts "lower barriers" goal
|
||||
- **Redis-backed rate limiting**: Unnecessary complexity for single-server MVP
|
||||
- **No rate limiting**: Violates FR-061 requirement, leaves system vulnerable to abuse
|
||||
- **Token bucket per session**: Requires session tracking for anonymous users, violates anonymity
|
||||
|
||||
**Dependencies**:
|
||||
- `Flask-Limiter` extension
|
||||
|
||||
---
|
||||
|
||||
## 6. Flask Best Practices for Simple HTML Applications
|
||||
|
||||
### Decision: Server-side rendering with Jinja2 templates, no JavaScript
|
||||
|
||||
**Rationale**:
|
||||
- User explicitly specifies "plain html using flask" and "does not use any css frameworks or javascript libraries"
|
||||
- Server-side rendering eliminates frontend build complexity
|
||||
- Jinja2 included with Flask, no additional dependencies
|
||||
- Forms use standard HTTP POST/GET, progressive enhancement approach
|
||||
|
||||
**Template Approach**:
|
||||
- Minimal inline CSS for basic layout (no framework)
|
||||
- Semantic HTML5 for accessibility
|
||||
- Server-side form validation with error display
|
||||
- Standard browser form controls (no custom widgets)
|
||||
|
||||
**Form Handling**:
|
||||
- POST requests for submissions
|
||||
- Server-side validation with error messages
|
||||
- Flash messages for user feedback
|
||||
- Redirect-after-POST pattern to prevent duplicate submissions
|
||||
|
||||
**No JavaScript Requirement** (Edge Case from spec.md line 105):
|
||||
- "What happens when a user's browser doesn't support JavaScript"
|
||||
- Answer: Application works fully without JavaScript (no JS used)
|
||||
- File uploads work via standard HTML `<input type="file" multiple>`
|
||||
|
||||
**Best Practices Applied**:
|
||||
- Flask app factory pattern for testability
|
||||
- Blueprint organization for routes
|
||||
- Environment-based configuration
|
||||
- CSRF protection via Flask-WTF (even for simple forms)
|
||||
|
||||
**Dependencies**:
|
||||
- Flask (includes Jinja2)
|
||||
- Flask-WTF for CSRF protection
|
||||
|
||||
---
|
||||
|
||||
## 7. Python Dependency Management
|
||||
|
||||
### Decision: requirements.txt with pinned versions for reproducibility
|
||||
|
||||
**Rationale**:
|
||||
- Simplest dependency management for Flask application
|
||||
- No need for Poetry/Pipenv complexity in MVP
|
||||
- Pin exact versions for reproducibility
|
||||
- Virtual environment assumed for isolation
|
||||
|
||||
**Core Dependencies** (estimated):
|
||||
```
|
||||
Flask==3.0.0
|
||||
Flask-Login==0.6.3
|
||||
Flask-Limiter==3.5.0
|
||||
Flask-WTF==1.2.1
|
||||
anthropic==0.8.0
|
||||
clamd==1.0.2
|
||||
bcrypt==4.1.2
|
||||
PyYAML==6.0.1
|
||||
pytest==7.4.3
|
||||
pytest-flask==1.3.0
|
||||
```
|
||||
|
||||
**Development Dependencies**:
|
||||
- pytest, pytest-flask for testing
|
||||
- black for code formatting
|
||||
- flake8 for linting
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack Summary
|
||||
|
||||
| Component | Technology | Rationale |
|
||||
|-----------|-----------|-----------|
|
||||
| **Web Framework** | Flask 3.0+ | Lightweight, simple, widely supported |
|
||||
| **Template Engine** | Jinja2 (built-in) | Server-side rendering, no JS needed |
|
||||
| **AI Provider** | Claude API (Anthropic) | User-specified, abstracted for future flexibility |
|
||||
| **Authentication** | Flask-Login + bcrypt | Standard session management, secure passwords |
|
||||
| **Rate Limiting** | Flask-Limiter | Prevent abuse, simple IP-based approach |
|
||||
| **Malware Scanning** | ClamAV + clamd | Open-source, reliable, on-premise |
|
||||
| **Storage** | Filesystem (YAML + Markdown) | Matches user requirements, simple, no DB |
|
||||
| **Testing** | pytest + pytest-flask | Industry standard, good Flask integration |
|
||||
| **Python Version** | 3.11+ | Modern, stable, good performance |
|
||||
| **Deployment** | Gunicorn (WSGI) | Production-ready Flask server |
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements Research
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
**Concurrent Submissions** (SC-012):
|
||||
- Target: 100 concurrent submissions without errors
|
||||
- Flask + Gunicorn with 4-8 worker processes should handle this
|
||||
- File I/O is bottleneck: consider async I/O if performance issues arise
|
||||
- AI analysis happens asynchronously (background task) to not block submission response
|
||||
|
||||
**Dashboard Performance** (SC-008):
|
||||
- Target: Load 1000 items in <3 seconds
|
||||
- File-based approach: Index product feedback directories, cache counts
|
||||
- Implement pagination (50 items per page)
|
||||
- Use lazy loading for file attachments (links, not embedded content)
|
||||
|
||||
**AI Analysis Time** (SC-007):
|
||||
- Target: <30 seconds for 95% of submissions
|
||||
- Claude Haiku model typically responds in 2-5 seconds for translation/categorization
|
||||
- Timeout: 45 seconds before marking as failed
|
||||
- Queue-based processing if needed (Python `queue` module or simple file-based queue)
|
||||
|
||||
### Security Considerations
|
||||
|
||||
**Anonymity Enforcement** (FR-055, SC-010):
|
||||
- Do NOT log IP addresses in feedback metadata
|
||||
- Rate limiting uses IP for abuse prevention only, not stored with feedback
|
||||
- No session cookies for anonymous submission
|
||||
- No analytics/tracking scripts
|
||||
|
||||
**File Upload Security**:
|
||||
- Validate MIME types server-side (don't trust client)
|
||||
- ClamAV scanning before storage
|
||||
- Sanitize filenames to prevent directory traversal
|
||||
- Store outside web root, serve via Flask route with access control
|
||||
|
||||
**HTTPS Requirement** (FR-064):
|
||||
- Deployment guide must specify reverse proxy (nginx) with TLS
|
||||
- Redirect HTTP to HTTPS
|
||||
- HSTS headers recommended
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for Implementation Phase
|
||||
|
||||
1. **Asynchronous AI Analysis**: Should analysis happen synchronously (user waits) or asynchronously (background job)?
|
||||
- **Recommendation**: Asynchronous - return success immediately, process in background
|
||||
- Implement simple file-based queue or use Python `threading` for MVP
|
||||
|
||||
2. **Admin Bootstrap**: How is the first admin user created?
|
||||
- **Recommendation**: CLI command or config file initialization script
|
||||
|
||||
3. **Email Notifications**: Out of scope (line 255) but commonly requested
|
||||
- **Recommendation**: Document as future enhancement, design hooks for extensibility
|
||||
|
||||
4. **Logging Strategy**: Structured logs for operational monitoring?
|
||||
- **Recommendation**: Python `logging` module, JSON format, separate file per environment
|
||||
|
||||
---
|
||||
|
||||
## Research Validation
|
||||
|
||||
All NEEDS CLARIFICATION items from Technical Context have been resolved:
|
||||
|
||||
| Item | Resolution | Document Section |
|
||||
|------|------------|------------------|
|
||||
| AI Integration | Pluggable interface, Claude as default | §1 |
|
||||
| File Upload - Malware Scanning | ClamAV + clamd | §2 |
|
||||
| File Upload - Storage Location | Filesystem: `data/products/.../feedback/.../attachments/` | §3 |
|
||||
| Authentication | Flask-Login + server-side sessions + bcrypt | §4 |
|
||||
| Rate Limiting | Flask-Limiter, 10/hour/IP | §5 |
|
||||
|
||||
**Next Phase**: Proceed to Phase 1 (data-model.md, contracts, quickstart.md)
|
||||
@@ -0,0 +1,279 @@
|
||||
# Feature Specification: Anonymous Feedback Platform (Reklamator)
|
||||
|
||||
**Feature Branch**: `001-build-an-application`
|
||||
**Created**: 2025-10-14
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Build an application enables users to anonymously hand in ideas, feature requests, bugs and complaints for a product or a service. The feedback will be then analysed using a modern ai model and translated to a language of the responsible person of that product or service. The analysis as well as belonging the documents or images will be stored toghether as files in a folder. The results are accessible for responsible persons of that product or service in a dashboard. The user is free to submit the feedback in any form or language in a text area. In addition to that he can upload up to three documents or images. The Idea is to lower barriers for feedback and to make it easier to get feedback from users."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Anonymous Feedback Submission (Priority: P1)
|
||||
|
||||
As an end user of a product or service, I want to submit feedback (ideas, bugs, complaints, feature requests) completely anonymously in any language without requiring authentication, so that I can share my thoughts without barriers or fear of identification.
|
||||
|
||||
**Why this priority**: This is the core value proposition - enabling barrier-free feedback submission. Without this, the entire application has no purpose. It must be the first deliverable.
|
||||
|
||||
**Independent Test**: Can be fully tested by visiting a feedback submission form for a product, entering feedback text in any language, optionally uploading up to 3 files (documents/images), and successfully submitting without any login or personal information required. The submission should complete and provide confirmation to the user.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am on a product's feedback submission page, **When** I enter feedback text in English and click submit, **Then** I see a success confirmation message and my feedback is recorded
|
||||
2. **Given** I am on a feedback submission page, **When** I enter feedback text in German, Spanish, Japanese, or any other language, **Then** the system accepts my feedback without language restrictions
|
||||
3. **Given** I am entering feedback, **When** I attach 1 document (PDF, DOCX, TXT) or image (JPG, PNG), **Then** the file is uploaded and associated with my feedback
|
||||
4. **Given** I am entering feedback, **When** I attach 3 documents/images (at maximum limit), **Then** all files are successfully uploaded
|
||||
5. **Given** I have attached 3 files, **When** I attempt to attach a 4th file, **Then** the system prevents the upload and informs me of the 3-file limit
|
||||
6. **Given** I submit feedback, **When** the submission completes, **Then** no personal identifying information about me is stored or required
|
||||
7. **Given** I submit feedback with only text and no files, **When** the submission completes, **Then** the feedback is accepted successfully
|
||||
8. **Given** I submit feedback with only files and no text, **When** the submission completes, **Then** the feedback is accepted successfully
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - AI-Powered Feedback Analysis and Translation (Priority: P2)
|
||||
|
||||
As a product owner or service manager, I want submitted feedback to be automatically analyzed by AI to categorize it (idea, bug, complaint, feature request), summarize key points, and translate it to my preferred language, so that I can quickly understand feedback regardless of the original language it was submitted in.
|
||||
|
||||
**Why this priority**: This is the intelligence layer that adds value beyond basic feedback collection. It enables product owners to efficiently process multilingual feedback. It depends on P1 (feedback must be submitted first), but can be developed and tested independently once P1 exists.
|
||||
|
||||
**Independent Test**: Can be tested by submitting feedback in various languages (e.g., German, French, Japanese) through the submission form and verifying that the analysis produces: (1) correct categorization (idea/bug/complaint/feature request), (2) a concise summary in the product owner's preferred language, (3) accurate translation of the original text, and (4) proper storage of analysis results with the original feedback.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** feedback has been submitted in Spanish, **When** AI analysis runs, **Then** the feedback is correctly categorized as one of: idea, bug, complaint, or feature request
|
||||
2. **Given** feedback has been submitted in Japanese, **When** AI analysis runs with target language set to English, **Then** the feedback text is accurately translated to English
|
||||
3. **Given** feedback contains a detailed description, **When** AI analysis runs, **Then** a concise summary (2-3 sentences) is generated capturing the main points in the target language
|
||||
4. **Given** feedback includes uploaded images, **When** AI analysis runs, **Then** images are stored as visual attachments (OCR is not performed)
|
||||
5. **Given** AI analysis completes, **When** storing results, **Then** the original feedback, translation, summary, category, and all uploaded files are stored together in a structured format
|
||||
6. **Given** AI analysis encounters an error or unsupported language, **When** storing results, **Then** the system flags the feedback as requiring manual review and stores the original content intact
|
||||
7. **Given** feedback is submitted in the same language as the product's target language, **When** AI analysis runs, **Then** categorization and summary still occur but translation may be skipped or indicate "original language"
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Product Owner Dashboard Access (Priority: P3)
|
||||
|
||||
As a product owner or responsible person for a product/service, I want to access a dashboard where I can view all feedback submitted for my product, including the AI analysis results, translations, and attached files, so that I can review and act on user feedback efficiently.
|
||||
|
||||
**Why this priority**: This completes the feedback loop by making analyzed feedback accessible. It's lower priority because feedback can still be collected and analyzed without the dashboard (results could be accessed via file system initially). However, it's essential for production use.
|
||||
|
||||
**Independent Test**: Can be tested by authenticating as a product owner, navigating to the dashboard, and verifying that all feedback items for their product(s) are displayed with: original text, translation, AI summary, category, submission date, and links to any attached files. The dashboard should be filterable and searchable.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a product owner with credentials, **When** I log into the dashboard, **Then** I see only feedback related to my assigned product(s)
|
||||
2. **Given** I am viewing the dashboard, **When** I click on a feedback item, **Then** I see the complete details including original text, detected original language, translation, AI summary, category, submission timestamp, and any attached files
|
||||
3. **Given** there are multiple feedback items, **When** I use the filter controls, **Then** I can filter by category (idea/bug/complaint/feature request)
|
||||
4. **Given** there are multiple feedback items, **When** I use the filter controls, **Then** I can filter by date range
|
||||
5. **Given** there are multiple feedback items, **When** I use the filter controls, **Then** I can filter by original language of submission
|
||||
6. **Given** I am viewing a feedback item with attached files, **When** I click on a file link, **Then** the file (document or image) opens or downloads for viewing
|
||||
7. **Given** there are many feedback items, **When** I use the search function, **Then** I can search by keywords in original text, translation, or summary
|
||||
8. **Given** I am viewing the dashboard, **When** new feedback is submitted and analyzed, **Then** it appears in my dashboard (within reasonable timeframe)
|
||||
9. **Given** I am viewing a feedback item, **When** I mark it with a status (reviewed, in progress, resolved, rejected), **Then** the status is saved and visible on subsequent views
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Product/Service Registration and Management ~~(Priority: P4)~~ **[DEFERRED FOR POC]**
|
||||
|
||||
~~As a platform administrator, I want to register new products or services in the system and assign responsible persons (product owners) to them, so that feedback can be properly routed and access controlled.~~
|
||||
|
||||
**Status**: **DEFERRED** - Out of scope for POC. Products and users will be managed manually via YAML configuration files.
|
||||
|
||||
**Rationale**: For a proof-of-concept, manual management of products and users through configuration files is sufficient. Building an admin UI for product/user management does not add value at this stage and can be implemented later if the POC proves successful and requires scaling.
|
||||
|
||||
**Manual Management Approach**:
|
||||
- Products: Edit `data/products/{product-id}/config.yaml` directly
|
||||
- Users: Edit `data/users.yaml` with bcrypt-hashed passwords
|
||||
- Product assignment: Update `product_ids` field in user records
|
||||
|
||||
**Original Requirements** (preserved for future reference):
|
||||
|
||||
~~As a platform administrator, I want to register new products or services in the system and assign responsible persons (product owners) to them, so that feedback can be properly routed and access controlled.~~
|
||||
|
||||
~~**Why this priority**: This is administrative infrastructure needed for multi-product support. It's lower priority because the MVP could work with a single hardcoded product. However, it's necessary for a scalable production system.~~
|
||||
|
||||
~~**Independent Test**: Can be tested by logging in as an administrator, creating a new product/service entry with details (name, description, preferred language for translations), assigning one or more product owners to it, and verifying that the product appears in the system with a unique feedback submission URL and that assigned owners can access its feedback in their dashboards.~~
|
||||
|
||||
~~**Acceptance Scenarios**:~~
|
||||
|
||||
~~1. **Given** I am an administrator, **When** I create a new product entry with name, description, and preferred language for feedback translations, **Then** the product is registered and assigned a unique identifier~~
|
||||
~~2. **Given** a product exists, **When** I assign a user as a product owner, **Then** that user gains access to view feedback for this product in their dashboard~~
|
||||
~~3. **Given** a product is registered, **When** I request the feedback submission URL, **Then** I receive a unique URL that end users can use to submit feedback for this specific product~~
|
||||
~~4. **Given** multiple products exist, **When** feedback is submitted via a product-specific URL, **Then** the feedback is correctly associated with that product and only visible to its assigned owners~~
|
||||
~~5. **Given** a product exists, **When** I update the preferred translation language setting, **Then** future feedback translations for this product use the new language preference~~
|
||||
~~6. **Given** a product is registered, **When** I view its settings, **Then** I can see statistics like total feedback count, submission URL, and assigned owners~~
|
||||
~~7. **Given** a product has historical feedback, **When** I archive the product, **Then** the feedback is preserved but the product is marked inactive and new submissions are disabled~~
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a user uploads a file exceeding the maximum file size limit (assumed 10MB per file)?
|
||||
- What happens when a user uploads an unsupported file type (e.g., executable, compressed archive)?
|
||||
- How does the system handle extremely long feedback text (e.g., 10,000+ characters)?
|
||||
- What happens if AI analysis fails (API timeout, service unavailable, unrecognizable content)?
|
||||
- How does the system handle feedback submitted in languages not supported by the translation model?
|
||||
- What happens when a user submits feedback with no text content (only files)?
|
||||
- What happens when a user submits completely empty feedback (no text, no files)?
|
||||
- How does the system handle identical or near-identical duplicate submissions?
|
||||
- What happens if a product owner is assigned to multiple products - how is the dashboard view organized?
|
||||
- How does the system handle image files that are too large or in exotic formats?
|
||||
- What happens when a user's browser doesn't support JavaScript - does the submission still work?
|
||||
- How does the system handle concurrent submissions from the same anonymous user?
|
||||
- What happens when a product owner tries to download a file that has been corrupted or deleted from storage?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Feedback Submission
|
||||
|
||||
- **FR-001**: System MUST provide a public, unauthenticated feedback submission form accessible via a unique URL for each product/service
|
||||
- **FR-002**: System MUST accept feedback text input of any length up to a reasonable maximum (10,000 characters)
|
||||
- **FR-003**: System MUST accept feedback text in any language without restrictions or validation on character sets
|
||||
- **FR-004**: System MUST allow users to optionally attach up to 3 files per feedback submission
|
||||
- **FR-005**: System MUST support document file formats including PDF, DOCX, TXT, and common image formats (JPG, PNG, GIF, WebP)
|
||||
- **FR-006**: System MUST enforce a maximum file size limit per attachment (10MB per file)
|
||||
- **FR-007**: System MUST NOT require or collect any personal identifying information from feedback submitters
|
||||
- **FR-008**: System MUST provide clear confirmation to users when feedback submission succeeds
|
||||
- **FR-009**: System MUST provide clear error messages when submission fails, without exposing system internals
|
||||
- **FR-010**: System MUST prevent users from attaching more than 3 files to a single submission
|
||||
- **FR-011**: System MUST accept feedback submissions that contain only text, only files, or both
|
||||
- **FR-012**: System MUST reject completely empty submissions (no text and no files)
|
||||
|
||||
#### AI Analysis and Translation
|
||||
|
||||
- **FR-013**: System MUST automatically analyze submitted feedback using an AI model to categorize it as one of: idea, feature request, bug, or complaint
|
||||
- **FR-014**: System MUST generate a concise summary (2-3 sentences maximum) of the feedback content in the product's target language
|
||||
- **FR-015**: System MUST translate the feedback text to the target language specified for the product/service
|
||||
- **FR-016**: System MUST preserve the original feedback text alongside the translation
|
||||
- **FR-017**: System MUST detect and record the original language of the submitted feedback
|
||||
- **FR-018**: System MUST handle feedback in any language supported by the AI translation model (minimum 50 languages)
|
||||
- **FR-019**: System MUST complete AI analysis and translation asynchronously to avoid blocking the user's submission
|
||||
- **FR-020**: System MUST store analysis failures gracefully and flag feedback items that could not be analyzed
|
||||
- **FR-021**: System MUST treat uploaded images as visual attachments (OCR is not performed)
|
||||
- **FR-022**: System MUST handle document attachments as reference materials without extracting text for analysis
|
||||
- **FR-023**: System MUST attempt to generate summary and category even when translation fails
|
||||
|
||||
#### Data Storage
|
||||
|
||||
- **FR-024**: System MUST store feedback, translations, summaries, categories, and attached files together as a cohesive unit
|
||||
- **FR-025**: System MUST organize stored feedback by product/service identifier
|
||||
- **FR-026**: System MUST preserve original filenames and file types for attachments
|
||||
- **FR-027**: System MUST record submission timestamp for each feedback item
|
||||
- **FR-028**: System MUST ensure stored feedback is accessible for retrieval by authorized product owners
|
||||
- **FR-029**: System MUST maintain data integrity between feedback items and their associated files
|
||||
- **FR-030**: System MUST store the detected or specified language of the original feedback submission
|
||||
- **FR-031**: System MUST organize files on disk by product and feedback item (folder structure as described: feedback + attachments stored together)
|
||||
|
||||
#### Dashboard and Access Control
|
||||
|
||||
- **FR-032**: System MUST provide an authenticated dashboard for product owners to view feedback
|
||||
- **FR-033**: System MUST restrict dashboard access so product owners only see feedback for their assigned products
|
||||
- **FR-034**: System MUST display feedback with all analysis results: original text, original language, translation, AI summary, category, and timestamp
|
||||
- **FR-035**: System MUST provide links to download or view attached files from the dashboard
|
||||
- **FR-036**: System MUST support filtering feedback by category (idea, bug, complaint, feature request)
|
||||
- **FR-037**: System MUST support filtering feedback by date range
|
||||
- **FR-038**: System MUST support filtering feedback by original language
|
||||
- **FR-039**: System MUST support filtering feedback by status (if product owner has marked items)
|
||||
- **FR-040**: System MUST support searching feedback by keyword across original text, translation, and summary
|
||||
- **FR-041**: System MUST display feedback in reverse chronological order (newest first) by default
|
||||
- **FR-042**: System MUST allow product owners to mark feedback with status indicators (new, reviewed, in progress, resolved, rejected)
|
||||
- **FR-043**: System MUST preserve status indicators when filtering or searching
|
||||
- **FR-044**: System MUST display file attachments with thumbnails for images and appropriate icons for documents
|
||||
|
||||
#### Product/Service Management **[DEFERRED FOR POC]**
|
||||
|
||||
~~Manual configuration via YAML files replaces admin UI for POC~~
|
||||
|
||||
- ~~**FR-045**: System MUST allow administrators to register new products or services~~ **[DEFERRED]** - Manual YAML editing
|
||||
- **FR-046**: System MUST require each product to have a unique name and identifier ✅ **[IMPLEMENTED]** - Via config.yaml
|
||||
- **FR-047**: System MUST allow setting a preferred target language for translations for each product ✅ **[IMPLEMENTED]** - Via config.yaml
|
||||
- ~~**FR-048**: System MUST allow assigning one or more product owners to each product~~ **[DEFERRED]** - Manual users.yaml editing
|
||||
- **FR-049**: System MUST generate a unique feedback submission URL for each registered product ✅ **[IMPLEMENTED]** - Via submission_url_slug
|
||||
- **FR-050**: System MUST support multiple products in the system simultaneously ✅ **[IMPLEMENTED]** - Product model supports this
|
||||
- ~~**FR-051**: System MUST allow updating product details and owner assignments~~ **[DEFERRED]** - Manual YAML editing
|
||||
- ~~**FR-052**: System MUST allow archiving products without deleting historical feedback~~ **[DEFERRED]** - Manual status field editing
|
||||
- **FR-053**: System MUST prevent new feedback submissions to archived products ✅ **[IMPLEMENTED]** - Checked in submission route
|
||||
- ~~**FR-054**: System MUST display product statistics (total feedback count, date created, active/archived status)~~ **[DEFERRED]** - Not needed for POC
|
||||
|
||||
#### Security and Privacy
|
||||
|
||||
- **FR-055**: System MUST ensure complete anonymity for feedback submitters (no IP logging, session tracking, or fingerprinting for identification purposes)
|
||||
- **FR-056**: System MUST authenticate product owners and administrators before granting dashboard access
|
||||
- **FR-057**: System MUST prevent unauthorized access to feedback data
|
||||
- **FR-058**: System MUST prevent directory traversal or unauthorized file access
|
||||
- **FR-059**: System MUST validate and sanitize all file uploads to prevent malicious file uploads
|
||||
- **FR-060**: System MUST scan uploaded files for malware before storage
|
||||
- **FR-061**: System MUST implement rate limiting on the submission form to prevent abuse (suggested: 10 submissions per hour per IP)
|
||||
- **FR-062**: System MUST retain feedback data indefinitely unless manually deleted by administrators
|
||||
- **FR-063**: System MUST use secure password storage (hashing) for product owner and administrator accounts
|
||||
- **FR-064**: System MUST use HTTPS for all communications
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Feedback Submission**: Represents a single feedback item submitted by an anonymous user. Contains: original text, original language, submission timestamp, category (assigned by AI), associated product identifier, status indicator, and references to attached files.
|
||||
|
||||
- **Product/Service**: Represents a product or service for which feedback can be collected. Contains: unique identifier, name, description, preferred language for translations, submission URL slug, assigned product owners, active/archived status, and creation date.
|
||||
|
||||
- **Analysis Result**: Represents the AI-generated analysis of a feedback submission. Contains: translated text, summary (in target language), detected category, original language detection, analysis timestamp, confidence scores, and any error information if analysis failed.
|
||||
|
||||
- **Attachment**: Represents a file (document or image) uploaded with feedback. Contains: filename, file type, file size, storage location reference, upload timestamp, and association with parent feedback submission.
|
||||
|
||||
- **Product Owner**: Represents an authenticated user responsible for reviewing feedback for one or more products. Contains: authentication credentials (email/password), name, assigned product identifiers, and access permissions.
|
||||
|
||||
- **Administrator**: Represents a privileged user who can register products, assign owners, manage system configuration, and access all feedback across products. Contains: authentication credentials, name, and admin privileges.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can submit feedback in under 1 minute, including optional file uploads
|
||||
- **SC-002**: System accepts feedback in at least 50 different languages without errors
|
||||
- **SC-003**: AI analysis correctly categorizes feedback with at least 80% accuracy when tested against manually labeled samples
|
||||
- **SC-004**: Translation quality is comprehensible and captures the main intent of the original feedback (measured by native speaker review of sample translations)
|
||||
- **SC-005**: Feedback submissions complete successfully 99% of the time (1% allowed for network failures outside system control)
|
||||
- **SC-006**: Product owners can find specific feedback using search or filters within 30 seconds
|
||||
- **SC-007**: AI analysis and translation complete within 30 seconds of submission for 95% of feedback items
|
||||
- **SC-008**: Dashboard loads and displays up to 1000 feedback items without noticeable performance degradation (under 3 seconds)
|
||||
- **SC-009**: Attached files (documents and images) are viewable and downloadable without corruption in 99.9% of cases
|
||||
- **SC-010**: Zero personal identifying information is stored for feedback submitters (verified by data audit)
|
||||
- **SC-011**: Product owners can only access feedback for their assigned products (verified by access control testing - 100% isolation)
|
||||
- **SC-012**: System handles at least 100 concurrent feedback submissions without errors or slowdowns
|
||||
- **SC-013**: File upload and storage maintains data integrity (checksums match) in 100% of successful uploads
|
||||
- **SC-014**: Dashboard search returns relevant results in under 2 seconds for databases with 10,000+ feedback items
|
||||
|
||||
## Assumptions
|
||||
|
||||
- AI translation and analysis will use a third-party service or model (e.g., OpenAI GPT, Google Translate API, DeepL, or similar)
|
||||
- Application interface will be in English (single language UI)
|
||||
- Standard web-based application accessible via modern browsers (Chrome, Firefox, Safari, Edge - latest 2 versions)
|
||||
- File uploads will be scanned for malware/viruses before storage using standard antivirus tools or services
|
||||
- Maximum of 10MB per file attachment is reasonable for typical user feedback scenarios
|
||||
- Product owners will have email-based accounts with password authentication
|
||||
- Administrators will be managed through a separate privileged interface or initial configuration
|
||||
- The system will support at least 50 major languages for feedback content via the AI model
|
||||
- Feedback submissions are retained indefinitely unless manually deleted by administrators
|
||||
- Dashboard will be a web-based responsive interface accessible on desktop and tablet devices
|
||||
- Single translation language per product (one preferred language, not multiple simultaneous translations)
|
||||
- Anonymous submission means no authentication required, but basic security measures (rate limiting, CAPTCHA if needed) are acceptable
|
||||
- Text-based feedback is the primary input; file attachments are supplementary
|
||||
- File storage will be on local disk or cloud storage (S3, similar) with folder-based organization
|
||||
- Products will be managed by administrators, not self-service registration
|
||||
- Initial MVP supports up to 100 products and 10,000 feedback items per product
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multilingual user interface (application will be in English only)
|
||||
- Real-time chat or back-and-forth communication with feedback submitters
|
||||
- Public display or sharing of feedback (all feedback is private to product owners)
|
||||
- Voting, rating, or community engagement features on feedback
|
||||
- Native mobile applications (responsive web interface is in scope)
|
||||
- Advanced analytics, trending, sentiment analysis graphs, or reporting dashboards (basic filtering/search only)
|
||||
- Automated email notifications for new feedback (may be added in future iterations)
|
||||
- Integration with external project management or issue tracking systems (Jira, Trello, GitHub Issues, etc.)
|
||||
- Video file uploads (documents and images only)
|
||||
- Real-time collaboration features for product owners (commenting, assigning within the system)
|
||||
- Automated feedback categorization training or model improvement by product owners
|
||||
- Advanced sentiment analysis beyond the four basic categories (idea, bug, complaint, feature request)
|
||||
- Multi-tenant SaaS with self-service product registration (administrator-managed only)
|
||||
- Bulk import/export of feedback data
|
||||
- API access for external systems
|
||||
- Custom branding or white-labeling per product
|
||||
@@ -0,0 +1,488 @@
|
||||
# Tasks: Anonymous Feedback Platform (Reklamator)
|
||||
|
||||
**Input**: Design documents from `/specs/001-build-an-application/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Per constitution's Test-First Discipline (NON-NEGOTIABLE), tests MUST be written before implementation for each user story.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `- [ ] [ID] [P?] [Story?] Description`
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3, US4)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions (from plan.md)
|
||||
- Project root: `reklamator/`
|
||||
- Application code: `app/`
|
||||
- Tests: `tests/`
|
||||
- Data storage: `data/`
|
||||
- Config: `config/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure
|
||||
|
||||
- [X] T001 Create project directory structure per plan.md (app/, tests/, config/, data/)
|
||||
- [X] T002 Initialize Python virtual environment and create requirements.txt with core dependencies
|
||||
- [X] T003 [P] Create pytest.ini configuration file in project root
|
||||
- [X] T004 [P] Create .env.example file documenting required environment variables
|
||||
- [X] T005 [P] Create run.py application entry point with Flask app factory import
|
||||
- [X] T006 [P] Create .gitignore for Python project (venv/, __pycache__/, .env, data/)
|
||||
- [X] T007 [P] Create config/development.py configuration class
|
||||
- [X] T008 [P] Create config/production.py configuration class
|
||||
- [X] T009 [P] Create config/testing.py configuration class
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [X] T010 Implement Flask app factory in app/__init__.py with config loading
|
||||
- [X] T011 [P] Create app/models/__init__.py module initialization
|
||||
- [X] T012 [P] Create app/services/__init__.py module initialization
|
||||
- [X] T013 [P] Create app/routes/__init__.py module initialization
|
||||
- [X] T014 [P] Create app/utils/__init__.py module initialization
|
||||
- [X] T015 [P] Create app/templates/ directory for Jinja2 templates
|
||||
- [X] T016 Implement base template layout in app/templates/base.html with minimal inline CSS
|
||||
- [X] T017 [P] Create app/utils/file_validator.py for MIME type and size validation
|
||||
- [X] T018 Implement filename sanitization in app/utils/file_validator.py
|
||||
- [X] T019 [P] Create data/users.yaml with initial admin user (bcrypt hashed password)
|
||||
- [X] T020 Implement User model in app/models/user.py with Flask-Login UserMixin
|
||||
- [X] T021 Implement user loading from users.yaml in app/models/user.py
|
||||
- [X] T022 Configure Flask-Login in app/__init__.py with login_manager
|
||||
- [X] T023 [P] Configure Flask-WTF CSRF protection in app/__init__.py
|
||||
- [X] T024 [P] Configure Flask-Limiter in app/__init__.py for rate limiting
|
||||
- [X] T025 Create app/services/auth.py with bcrypt password verification
|
||||
- [X] T026 [P] Create tests/conftest.py with Flask test client fixture
|
||||
- [X] T027 [P] Create tests/contract/__init__.py
|
||||
- [X] T028 [P] Create tests/integration/__init__.py
|
||||
- [X] T029 [P] Create tests/unit/__init__.py
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Anonymous Feedback Submission (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Enable anonymous users to submit feedback with text and/or up to 3 file attachments without authentication
|
||||
|
||||
**Independent Test**: Visit /submit/{product_slug}, enter feedback text in any language, optionally attach up to 3 files, submit successfully without login, receive confirmation
|
||||
|
||||
### Tests for User Story 1 (MUST WRITE FIRST) ⚠️
|
||||
|
||||
**NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [X] T030 [P] [US1] Contract test for GET /submit/{product_slug} in tests/contract/test_submission_routes.py
|
||||
- [X] T031 [P] [US1] Contract test for POST /submit/{product_slug} with text only in tests/contract/test_submission_routes.py
|
||||
- [X] T032 [P] [US1] Contract test for POST /submit/{product_slug} with files only in tests/contract/test_submission_routes.py
|
||||
- [X] T033 [P] [US1] Contract test for POST /submit/{product_slug} with text and files in tests/contract/test_submission_routes.py
|
||||
- [X] T034 [P] [US1] Contract test for empty submission rejection (400) in tests/contract/test_submission_routes.py
|
||||
- [X] T035 [P] [US1] Contract test for >3 files rejection (400) in tests/contract/test_submission_routes.py
|
||||
- [X] T036 [P] [US1] Contract test for >10MB file rejection (413) in tests/contract/test_submission_routes.py
|
||||
- [X] T037 [P] [US1] Contract test for unsupported file type rejection (400) in tests/contract/test_submission_routes.py
|
||||
- [X] T038 [P] [US1] Contract test for rate limiting (429 after 10 submissions) in tests/contract/test_submission_routes.py
|
||||
- [X] T039 [P] [US1] Integration test for complete feedback submission flow in tests/integration/test_feedback_submission_flow.py
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T040 [P] [US1] Create Product model in app/models/product.py with YAML loading
|
||||
- [X] T041 [P] [US1] Create Feedback model in app/models/feedback.py with validation
|
||||
- [X] T042 [US1] Implement FeedbackStorageService in app/services/feedback_storage.py (depends on T040, T041)
|
||||
- [X] T043 [US1] Implement create_feedback method in FeedbackStorageService (UUID generation, directory creation)
|
||||
- [X] T044 [US1] Implement save_metadata method in FeedbackStorageService (YAML writing)
|
||||
- [X] T045 [US1] Implement save_content method in FeedbackStorageService (text file writing)
|
||||
- [X] T046 [US1] Implement save_attachments method in FeedbackStorageService (file copying with sanitization)
|
||||
- [X] T047 [US1] Integrate ClamAV scanning in app/utils/file_validator.py with clamd library
|
||||
- [X] T048 [US1] Create submission routes blueprint in app/routes/submission.py
|
||||
- [X] T049 [US1] Implement GET /submit/{product_slug} route returning submission form template
|
||||
- [X] T050 [US1] Create submission form template in app/templates/submission_form.html
|
||||
- [X] T051 [US1] Implement POST /submit/{product_slug} route with form handling
|
||||
- [X] T052 [US1] Add validation logic in POST route (text or files required, max 3 files, etc.)
|
||||
- [X] T053 [US1] Add file type validation in POST route using file_validator
|
||||
- [X] T054 [US1] Add file size validation in POST route (max 10MB per file)
|
||||
- [X] T055 [US1] Add ClamAV virus scanning in POST route before storage
|
||||
- [X] T056 [US1] Integrate FeedbackStorageService in POST route to save feedback
|
||||
- [X] T057 [US1] Add rate limiting decorator to POST route (10/hour/IP)
|
||||
- [X] T058 [US1] Create success confirmation template in app/templates/submission_success.html
|
||||
- [X] T059 [US1] Create error display template in app/templates/submission_error.html
|
||||
- [X] T060 [US1] Add error handling for archived products (404 response)
|
||||
- [X] T061 [US1] Add error handling for non-existent products (404 response)
|
||||
- [X] T062 [US1] Register submission blueprint in app/__init__.py
|
||||
- [X] T063 [US1] Create test product config.yaml in data/products/test-product/ for testing
|
||||
- [X] T064 [US1] Verify no IP address logging in feedback metadata (FR-055 compliance)
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional - anonymous feedback submission works end-to-end
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - AI-Powered Feedback Analysis and Translation (Priority: P2)
|
||||
|
||||
**Goal**: Automatically analyze submitted feedback using AI to categorize, summarize, and translate to product owner's preferred language
|
||||
|
||||
**Independent Test**: Submit feedback in non-English language (e.g., German), verify analysis.md is generated with correct category, summary in English, and translation
|
||||
|
||||
### Tests for User Story 2 (MUST WRITE FIRST) ⚠️
|
||||
|
||||
- [X] T065 [P] [US2] Unit test for AIAnalyzer interface in tests/unit/test_ai_analyzer.py
|
||||
- [X] T066 [P] [US2] Unit test for ClaudeAnalyzer categorization in tests/unit/test_ai_analyzer.py
|
||||
- [X] T067 [P] [US2] Unit test for ClaudeAnalyzer translation in tests/unit/test_ai_analyzer.py
|
||||
- [X] T068 [P] [US2] Unit test for ClaudeAnalyzer summary generation in tests/unit/test_ai_analyzer.py
|
||||
- [X] T069 [P] [US2] Unit test for analysis error handling in tests/unit/test_ai_analyzer.py
|
||||
- [X] T070 [P] [US2] Integration test for full AI analysis flow in tests/integration/test_ai_analysis_flow.py
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T071 [P] [US2] Create AIAnalyzer abstract base class in app/services/ai_analyzer.py
|
||||
- [X] T072 [P] [US2] Create AnalysisResult dataclass in app/models/feedback.py
|
||||
- [X] T073 [US2] Implement ClaudeAnalyzer class in app/services/ai_analyzer.py (depends on T071)
|
||||
- [X] T074 [US2] Implement analyze_feedback method in ClaudeAnalyzer using Anthropic SDK
|
||||
- [X] T075 [US2] Design prompt for Claude API (categorize + summarize + translate in single call)
|
||||
- [X] T076 [US2] Implement language detection in ClaudeAnalyzer
|
||||
- [X] T077 [US2] Implement category extraction from Claude response
|
||||
- [X] T078 [US2] Implement summary extraction from Claude response
|
||||
- [X] T079 [US2] Implement translation extraction from Claude response
|
||||
- [X] T080 [US2] Add error handling for API timeouts (45s timeout)
|
||||
- [X] T081 [US2] Add retry logic for transient API failures
|
||||
- [X] T082 [US2] Implement save_analysis method in FeedbackStorageService (writes analysis.md)
|
||||
- [X] T083 [US2] Create analysis markdown template format in FeedbackStorageService
|
||||
- [X] T084 [US2] Implement background analysis task using Python threading module
|
||||
- [X] T085 [US2] Integrate background analysis trigger in submission POST route after successful save
|
||||
- [X] T086 [US2] Update feedback status to "analyzing" when background task starts
|
||||
- [X] T087 [US2] Update feedback status to "analyzed" when analysis succeeds
|
||||
- [X] T088 [US2] Update feedback status to "analysis_failed" on error
|
||||
- [X] T089 [US2] Store detected language in metadata.yaml original_language field
|
||||
- [X] T090 [US2] Add ANTHROPIC_API_KEY to .env.example file
|
||||
- [X] T091 [US2] Verify analysis preserves original content.txt file (FR-016)
|
||||
- [X] T092 [US2] Verify images are stored but not analyzed via OCR (FR-021)
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 2 work together - feedback is submitted AND automatically analyzed
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Product Owner Dashboard Access (Priority: P3)
|
||||
|
||||
**Goal**: Provide authenticated dashboard for product owners to view, filter, search, and manage feedback for their assigned products
|
||||
|
||||
**Independent Test**: Login as product owner, view dashboard with feedback list, filter by category, search by keyword, view feedback detail, update status, download attachments
|
||||
|
||||
### Tests for User Story 3 (MUST WRITE FIRST) ⚠️
|
||||
|
||||
- [X] T093 [P] [US3] Contract test for GET /login in tests/contract/test_dashboard_routes.py
|
||||
- [X] T094 [P] [US3] Contract test for POST /login with valid credentials in tests/contract/test_dashboard_routes.py
|
||||
- [X] T095 [P] [US3] Contract test for POST /login with invalid credentials (401) in tests/contract/test_dashboard_routes.py
|
||||
- [X] T096 [P] [US3] Contract test for GET /logout in tests/contract/test_dashboard_routes.py
|
||||
- [X] T097 [P] [US3] Contract test for GET /dashboard (authenticated) in tests/contract/test_dashboard_routes.py
|
||||
- [X] T098 [P] [US3] Contract test for GET /dashboard (unauthenticated redirect) in tests/contract/test_dashboard_routes.py
|
||||
- [X] T099 [P] [US3] Contract test for GET /dashboard with filters in tests/contract/test_dashboard_routes.py
|
||||
- [X] T100 [P] [US3] Contract test for GET /dashboard with search query in tests/contract/test_dashboard_routes.py
|
||||
- [X] T101 [P] [US3] Contract test for GET /feedback/{id} detail view in tests/contract/test_dashboard_routes.py
|
||||
- [X] T102 [P] [US3] Contract test for POST /feedback/{id}/status update in tests/contract/test_dashboard_routes.py
|
||||
- [X] T103 [P] [US3] Contract test for GET /feedback/{id}/attachment/{filename} download in tests/contract/test_dashboard_routes.py
|
||||
- [X] T104 [P] [US3] Contract test for access control (owner sees only assigned products) in tests/contract/test_dashboard_routes.py
|
||||
- [X] T105 [P] [US3] Integration test for dashboard access flow in tests/integration/test_dashboard_access_flow.py
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T106 [P] [US3] Create dashboard routes blueprint in app/routes/dashboard.py
|
||||
- [X] T107 [P] [US3] Implement GET /login route returning login form template
|
||||
- [X] T108 [P] [US3] Create login form template in app/templates/login.html
|
||||
- [X] T109 [US3] Implement POST /login route with authentication logic
|
||||
- [X] T110 [US3] Implement GET /logout route with Flask-Login logout_user
|
||||
- [X] T111 [US3] Implement load_feedback_list method in FeedbackStorageService
|
||||
- [X] T112 [US3] Implement pagination logic in load_feedback_list (50 items/page)
|
||||
- [X] T113 [US3] Implement filter_by_category in FeedbackStorageService
|
||||
- [X] T114 [US3] Implement filter_by_date_range in FeedbackStorageService
|
||||
- [X] T115 [US3] Implement filter_by_language in FeedbackStorageService
|
||||
- [X] T116 [US3] Implement filter_by_status in FeedbackStorageService
|
||||
- [X] T117 [US3] Implement search_feedback in FeedbackStorageService (keyword search in text/translation/summary)
|
||||
- [X] T118 [US3] Implement sort by timestamp (newest first, FR-041)
|
||||
- [X] T119 [US3] Implement GET /dashboard route with @login_required decorator
|
||||
- [X] T120 [US3] Add product access control in GET /dashboard (owner sees only assigned products)
|
||||
- [X] T121 [US3] Apply filters and search from query parameters in GET /dashboard
|
||||
- [X] T122 [US3] Create dashboard template in app/templates/dashboard.html with filter form
|
||||
- [X] T123 [US3] Add pagination links to dashboard template
|
||||
- [X] T124 [US3] Implement GET /feedback/{feedback_id} detail route with @login_required
|
||||
- [X] T125 [US3] Add access control check in detail route (verify owner has access to product)
|
||||
- [X] T126 [US3] Load analysis.md content in detail route
|
||||
- [X] T127 [US3] Create feedback detail template in app/templates/feedback_detail.html
|
||||
- [X] T128 [US3] Display original text, translation, summary, category, attachments in detail template
|
||||
- [X] T129 [US3] Implement POST /feedback/{feedback_id}/status route for status updates
|
||||
- [X] T130 [US3] Update metadata.yaml status field in status update route
|
||||
- [X] T131 [US3] Implement GET /feedback/{feedback_id}/attachment/{filename} route for file downloads
|
||||
- [X] T132 [US3] Add path traversal prevention in attachment download route
|
||||
- [X] T133 [US3] Add access control in attachment download route
|
||||
- [X] T134 [US3] Serve files with correct Content-Type and Content-Disposition headers
|
||||
- [X] T135 [US3] Add error template for access denied (403) in app/templates/error_403.html
|
||||
- [X] T136 [US3] Add error template for not found (404) in app/templates/error_404.html
|
||||
- [X] T137 [US3] Register dashboard blueprint in app/__init__.py
|
||||
- [X] T138 [US3] Create test product owner in data/users.yaml for testing
|
||||
- [X] T139 [US3] Verify admin users have access to all products (bypass assigned_product_ids check)
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1, 2, AND 3 work together - feedback is submitted, analyzed, and viewable in dashboard
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Product/Service Registration and Management ~~(Priority: P4)~~ **[DEFERRED FOR POC]**
|
||||
|
||||
**Status**: **OUT OF SCOPE FOR POC** - Products and users managed manually via YAML files
|
||||
|
||||
**Rationale**: For a proof-of-concept, manual management of products and users through configuration files is sufficient. Building an admin UI for product/user management does not add value at this stage. The underlying product/user models and access control are already implemented - only the admin UI routes are deferred.
|
||||
|
||||
**Manual Management Guide**:
|
||||
- **Create Product**: Create `data/products/{product-id}/` directory with `config.yaml`
|
||||
- **Edit Product**: Modify `data/products/{product-id}/config.yaml` (fields: product_id, name, submission_url_slug, owner_language, assigned_owner_ids, status)
|
||||
- **Create User**: Add entry to `data/users.yaml` with bcrypt-hashed password
|
||||
- **Assign Product Owners**: Update `product_ids` array in user record in `data/users.yaml`
|
||||
- **Archive Product**: Set `status: archived` in product's `config.yaml`
|
||||
|
||||
**What's Already Implemented** (no admin UI needed):
|
||||
- ✅ Product model with YAML loading (`app/models/product.py`)
|
||||
- ✅ User model with role-based access (`app/models/user.py`)
|
||||
- ✅ Multi-product support in submission and dashboard
|
||||
- ✅ Product access control (owners see assigned, admins see all)
|
||||
- ✅ Archived product blocking (submission route checks status)
|
||||
- ✅ Unique submission URLs per product (via submission_url_slug)
|
||||
|
||||
**Future Consideration**: If POC proves successful and scaling is needed, Phase 6 tasks below can be implemented to add admin UI.
|
||||
|
||||
---
|
||||
|
||||
### ~~Tests for User Story 4 (DEFERRED)~~ ⚠️
|
||||
|
||||
- [ ] T140 [P] [US4] Contract test for GET /admin/products in tests/contract/test_admin_routes.py
|
||||
- [ ] T141 [P] [US4] Contract test for GET /admin/products/new in tests/contract/test_admin_routes.py
|
||||
- [ ] T142 [P] [US4] Contract test for POST /admin/products with valid data in tests/contract/test_admin_routes.py
|
||||
- [ ] T143 [P] [US4] Contract test for POST /admin/products with duplicate ID (400) in tests/contract/test_admin_routes.py
|
||||
- [ ] T144 [P] [US4] Contract test for GET /admin/products/{id}/edit in tests/contract/test_admin_routes.py
|
||||
- [ ] T145 [P] [US4] Contract test for POST /admin/products/{id} update in tests/contract/test_admin_routes.py
|
||||
- [ ] T146 [P] [US4] Contract test for POST /admin/products/{id}/archive in tests/contract/test_admin_routes.py
|
||||
- [ ] T147 [P] [US4] Contract test for POST /admin/products/{id}/unarchive in tests/contract/test_admin_routes.py
|
||||
- [ ] T148 [P] [US4] Contract test for GET /admin/users in tests/contract/test_admin_routes.py
|
||||
- [ ] T149 [P] [US4] Contract test for POST /admin/users create in tests/contract/test_admin_routes.py
|
||||
- [ ] T150 [P] [US4] Contract test for POST /admin/users/{id} update in tests/contract/test_admin_routes.py
|
||||
- [ ] T151 [P] [US4] Contract test for POST /admin/users/{id}/delete in tests/contract/test_admin_routes.py
|
||||
- [ ] T152 [P] [US4] Contract test for admin role requirement (403 for non-admin) in tests/contract/test_admin_routes.py
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [ ] T153 [P] [US4] Create admin routes blueprint in app/routes/admin.py
|
||||
- [ ] T154 [US4] Create ProductService in app/services/product_service.py
|
||||
- [ ] T155 [US4] Implement load_all_products in ProductService
|
||||
- [ ] T156 [US4] Implement load_product_by_id in ProductService
|
||||
- [ ] T157 [US4] Implement create_product in ProductService (creates directory + config.yaml)
|
||||
- [ ] T158 [US4] Implement update_product in ProductService (updates config.yaml)
|
||||
- [ ] T159 [US4] Implement archive_product in ProductService (sets status to archived)
|
||||
- [ ] T160 [US4] Implement validate_product_id_unique in ProductService
|
||||
- [ ] T161 [US4] Implement validate_submission_url_slug_unique in ProductService
|
||||
- [ ] T162 [US4] Create UserService in app/services/user_service.py
|
||||
- [ ] T163 [US4] Implement load_all_users in UserService (from users.yaml)
|
||||
- [ ] T164 [US4] Implement create_user in UserService (appends to users.yaml with bcrypt hash)
|
||||
- [ ] T165 [US4] Implement update_user in UserService (updates users.yaml)
|
||||
- [ ] T166 [US4] Implement delete_user in UserService (removes from users.yaml)
|
||||
- [ ] T167 [US4] Implement generate_unique_user_id in UserService
|
||||
- [ ] T168 [US4] Implement GET /admin/products route with @login_required and admin check
|
||||
- [ ] T169 [US4] Create admin products list template in app/templates/admin_products.html
|
||||
- [ ] T170 [US4] Implement GET /admin/products/new route returning form template
|
||||
- [ ] T171 [US4] Create product create form template in app/templates/admin_product_form.html
|
||||
- [ ] T172 [US4] Implement POST /admin/products route with validation
|
||||
- [ ] T173 [US4] Add unique ID and slug validation in POST /admin/products
|
||||
- [ ] T174 [US4] Add at least 1 owner requirement validation in POST /admin/products
|
||||
- [ ] T175 [US4] Implement GET /admin/products/{id}/edit route returning pre-filled form
|
||||
- [ ] T176 [US4] Implement POST /admin/products/{id} route for updates
|
||||
- [ ] T177 [US4] Implement POST /admin/products/{id}/archive route
|
||||
- [ ] T178 [US4] Implement POST /admin/products/{id}/unarchive route
|
||||
- [ ] T179 [US4] Implement GET /admin/users route with @login_required and admin check
|
||||
- [ ] T180 [US4] Create admin users list template in app/templates/admin_users.html
|
||||
- [ ] T181 [US4] Implement GET /admin/users/new route returning form template
|
||||
- [ ] T182 [US4] Create user create form template in app/templates/admin_user_form.html
|
||||
- [ ] T183 [US4] Implement POST /admin/users route with bcrypt password hashing
|
||||
- [ ] T184 [US4] Implement GET /admin/users/{id}/edit route returning pre-filled form
|
||||
- [ ] T185 [US4] Implement POST /admin/users/{id} route for updates (re-hash password if changed)
|
||||
- [ ] T186 [US4] Implement POST /admin/users/{id}/delete route with self-delete prevention
|
||||
- [ ] T187 [US4] Add admin role check decorator in app/utils/decorators.py
|
||||
- [ ] T188 [US4] Apply admin_required decorator to all admin routes
|
||||
- [ ] T189 [US4] Register admin blueprint in app/__init__.py
|
||||
- [ ] T190 [US4] Update submission route to check product status (reject if archived, FR-053)
|
||||
- [ ] T191 [US4] Add product statistics calculation in ProductService (total feedback count)
|
||||
- [ ] T192 [US4] Display statistics in admin products list template
|
||||
|
||||
**Checkpoint**: All user stories (1-4) are now complete and independently functional
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [X] T193 [P] Add comprehensive error logging in all routes using Python logging module
|
||||
- [X] T194 [P] Configure structured logging in app/__init__.py (JSON format)
|
||||
- [X] T195 [P] Create deployment guide in docs/deployment.md (ClamAV setup, nginx reverse proxy, HTTPS)
|
||||
- [X] T196 [P] Add HSTS headers in production config for HTTPS enforcement (FR-064)
|
||||
- [X] T197 [P] Verify CSRF protection on all POST routes (Flask-WTF)
|
||||
- [X] T198 [P] Verify session cookie security flags (HttpOnly, Secure, SameSite)
|
||||
- [X] T199 [P] Add input sanitization for all user inputs (XSS prevention)
|
||||
- [X] T200 [P] Add integration test for 100 concurrent submissions (SC-012) in tests/integration/test_performance.py
|
||||
- [X] T201 [P] Verify dashboard loads 1000 items in <3s (SC-008) in tests/integration/test_performance.py
|
||||
- [X] T202 [P] Run quickstart.md validation (manual testing of developer setup guide)
|
||||
- [X] T203 [P] Add README.md with project overview and quick start link
|
||||
- [X] T204 [P] Code cleanup and consistency check (PEP 8 compliance)
|
||||
- [X] T205 [P] Run flake8 linting on all Python files
|
||||
- [X] T206 [P] Run black formatting on all Python files
|
||||
- [X] T207 [P] Verify no hardcoded secrets in code (API keys, passwords)
|
||||
- [X] T208 [P] Add health check endpoint /health for monitoring
|
||||
- [X] T209 [P] Add environment variable validation on startup
|
||||
- [X] T210 [P] Create requirements-dev.txt for development dependencies
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational (Phase 2) - MVP deliverable
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational (Phase 2) + User Story 1 (feedback must exist to analyze)
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational (Phase 2) + User Stories 1 & 2 (feedback and analysis must exist to view)
|
||||
- **User Story 4 (Phase 6)**: Depends on Foundational (Phase 2) - Can proceed in parallel with US1-3 if staffed
|
||||
- **Polish (Phase 7)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: INDEPENDENT - Can start after Foundational, no other story dependencies
|
||||
- **User Story 2 (P2)**: Depends on User Story 1 (must have feedback to analyze)
|
||||
- **User Story 3 (P3)**: Depends on User Stories 1 & 2 (must have analyzed feedback to display)
|
||||
- **User Story 4 (P4)**: INDEPENDENT - Can start after Foundational in parallel with others (admin features)
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written FIRST and verified to FAIL before implementation (constitution requirement)
|
||||
- Models before services (services depend on models)
|
||||
- Services before routes (routes depend on services)
|
||||
- Core implementation before integration
|
||||
- Story complete and tested before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Setup Phase (Phase 1)**:
|
||||
- Tasks T003-T009 can all run in parallel (different files)
|
||||
|
||||
**Foundational Phase (Phase 2)**:
|
||||
- T011-T015, T017, T019, T021, T023-T024, T027-T029 can run in parallel (different files)
|
||||
|
||||
**User Story 1 Tests**:
|
||||
- T030-T038 can run in parallel (all test different scenarios in same file but different test functions)
|
||||
|
||||
**User Story 1 Models**:
|
||||
- T040-T041 can run in parallel (different model files)
|
||||
|
||||
**User Story 2 Tests**:
|
||||
- T065-T069 can run in parallel (different test functions)
|
||||
|
||||
**User Story 3 Tests**:
|
||||
- T093-T104 can run in parallel (different test functions)
|
||||
|
||||
**User Story 4 Tests**:
|
||||
- T140-T152 can run in parallel (different test functions)
|
||||
|
||||
**Polish Phase (Phase 7)**:
|
||||
- Most tasks T193-T210 can run in parallel (different concerns)
|
||||
|
||||
**Team Parallelization**:
|
||||
- After Foundational (Phase 2), User Story 1 and User Story 4 can proceed in parallel (different domains)
|
||||
- Once US1 is complete, US2 can start while US4 continues
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1 Tests
|
||||
|
||||
```bash
|
||||
# Launch all contract tests for User Story 1 together:
|
||||
Task: "Contract test for GET /submit/{product_slug} in tests/contract/test_submission_routes.py"
|
||||
Task: "Contract test for POST /submit/{product_slug} with text only in tests/contract/test_submission_routes.py"
|
||||
Task: "Contract test for POST /submit/{product_slug} with files only in tests/contract/test_submission_routes.py"
|
||||
# ... all T030-T038 can be written in parallel
|
||||
|
||||
# Launch both model creation tasks together:
|
||||
Task: "Create Product model in app/models/product.py with YAML loading"
|
||||
Task: "Create Feedback model in app/models/feedback.py with validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (T001-T009)
|
||||
2. Complete Phase 2: Foundational (T010-T029) - CRITICAL, blocks all stories
|
||||
3. Complete Phase 3: User Story 1 (T030-T064)
|
||||
- Write tests FIRST (T030-T039)
|
||||
- Verify tests FAIL
|
||||
- Implement (T040-T064)
|
||||
- Verify tests PASS
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||
5. Can deploy/demo basic feedback submission at this point
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational (T001-T029) → Foundation ready
|
||||
2. Add User Story 1 (T030-T064) → Test independently → **MVP Deployment** 🎯
|
||||
3. Add User Story 2 (T065-T092) → Test independently → Deploy with AI analysis
|
||||
4. Add User Story 3 (T093-T139) → Test independently → Deploy with dashboard
|
||||
5. Add User Story 4 (T140-T192) → Test independently → Deploy multi-product support
|
||||
6. Polish (T193-T210) → Production-ready deployment
|
||||
|
||||
Each story adds value without breaking previous stories.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers after Foundational phase completes:
|
||||
|
||||
**Option 1: Sequential (Safest)**
|
||||
- Complete US1 → US2 → US3 → US4 in order (dependencies respected)
|
||||
|
||||
**Option 2: Parallel (If staffed)**
|
||||
- Developer A: User Story 1 (T030-T064)
|
||||
- Developer B: User Story 4 (T140-T192) - can work in parallel
|
||||
- Once US1 complete: Developer A starts US2
|
||||
- Once US2 complete: Developer A starts US3
|
||||
- Integrate and test all together
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
- **Total Tasks**: 210
|
||||
- **User Story 1 (P1 - MVP)**: 35 tasks (T030-T064)
|
||||
- **User Story 2 (P2)**: 28 tasks (T065-T092)
|
||||
- **User Story 3 (P3)**: 47 tasks (T093-T139)
|
||||
- **User Story 4 (P4)**: 53 tasks (T140-T192)
|
||||
- **Setup + Foundational**: 29 tasks (T001-T029)
|
||||
- **Polish**: 18 tasks (T193-T210)
|
||||
|
||||
**Parallel Opportunities**: ~80 tasks marked [P] can run in parallel with appropriate team structure
|
||||
|
||||
**MVP Scope**: Phases 1-3 (T001-T064) = 64 tasks for basic feedback submission
|
||||
|
||||
**Test Coverage**: 39 test tasks (constitution-mandated test-first approach)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files or independent test functions, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story designed to be independently completable and testable
|
||||
- **Constitution Requirement**: Tests MUST be written and verified to FAIL before implementing each story
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Avoid: vague tasks, same-file conflicts, cross-story dependencies that break independence
|
||||
- File paths use plan.md structure (app/, tests/, data/, config/)
|
||||
- All security requirements (FR-055 to FR-064) integrated into relevant tasks
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests package"""
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Pytest configuration and fixtures"""
|
||||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from app import create_app
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing"""
|
||||
app = create_app('testing')
|
||||
|
||||
# Create temporary data directory
|
||||
with app.app_context():
|
||||
os.makedirs(app.config['DATA_DIR'], exist_ok=True)
|
||||
|
||||
yield app
|
||||
|
||||
# Cleanup temporary directory
|
||||
with app.app_context():
|
||||
if os.path.exists(app.config['DATA_DIR']):
|
||||
shutil.rmtree(app.config['DATA_DIR'])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""Create test CLI runner"""
|
||||
return app.test_cli_runner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(app):
|
||||
"""Create administrator user for testing"""
|
||||
with app.app_context():
|
||||
user = User.create(
|
||||
username='admin',
|
||||
email='admin@example.com',
|
||||
password='admin123',
|
||||
role='administrator'
|
||||
)
|
||||
yield user
|
||||
# Cleanup
|
||||
user.delete()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def product_owner_user(app):
|
||||
"""Create product owner user for testing"""
|
||||
with app.app_context():
|
||||
user = User.create(
|
||||
username='owner',
|
||||
email='owner@example.com',
|
||||
password='owner123',
|
||||
role='product_owner',
|
||||
product_ids=['prod_0001']
|
||||
)
|
||||
yield user
|
||||
# Cleanup
|
||||
user.delete()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_admin_client(client, admin_user):
|
||||
"""Create authenticated admin client"""
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': 'admin',
|
||||
'password': 'admin123'
|
||||
}, follow_redirects=True)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_owner_client(client, product_owner_user):
|
||||
"""Create authenticated product owner client"""
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': 'owner',
|
||||
'password': 'owner123'
|
||||
}, follow_redirects=True)
|
||||
yield client
|
||||
@@ -0,0 +1 @@
|
||||
"""Contract tests package"""
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Contract tests for dashboard routes"""
|
||||
import pytest
|
||||
import os
|
||||
import yaml
|
||||
import io
|
||||
from unittest.mock import Mock, patch
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_product(app):
|
||||
"""Create a test product with feedback"""
|
||||
with app.app_context():
|
||||
# Create test product directory and config
|
||||
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
|
||||
os.makedirs(product_dir, exist_ok=True)
|
||||
|
||||
# Create product config
|
||||
config_file = os.path.join(product_dir, 'config.yaml')
|
||||
config_data = {
|
||||
'product_id': 'test-product',
|
||||
'name': 'Test Product',
|
||||
'submission_url_slug': 'test-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': ['usr_owner1'],
|
||||
'status': 'active'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
# Create feedback directory
|
||||
feedback_dir = os.path.join(product_dir, 'feedback')
|
||||
os.makedirs(feedback_dir, exist_ok=True)
|
||||
|
||||
# Create test feedback
|
||||
feedback_id = 'test-feedback-001'
|
||||
feedback_path = os.path.join(feedback_dir, feedback_id)
|
||||
os.makedirs(feedback_path, exist_ok=True)
|
||||
|
||||
# Create feedback metadata
|
||||
metadata = {
|
||||
'feedback_id': feedback_id,
|
||||
'product_id': 'test-product',
|
||||
'status': 'new',
|
||||
'submitted_at': '2025-10-16T10:00:00Z',
|
||||
'has_attachments': True,
|
||||
'attachment_count': 1,
|
||||
'category': 'bug',
|
||||
'original_language': 'en'
|
||||
}
|
||||
|
||||
with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f:
|
||||
yaml.dump(metadata, f)
|
||||
|
||||
# Create feedback content
|
||||
with open(os.path.join(feedback_path, 'content.txt'), 'w') as f:
|
||||
f.write('Test feedback content')
|
||||
|
||||
# Create attachments directory and file
|
||||
attachments_dir = os.path.join(feedback_path, 'attachments')
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
|
||||
with open(os.path.join(attachments_dir, 'test.txt'), 'w') as f:
|
||||
f.write('test attachment content')
|
||||
|
||||
yield {
|
||||
'product_id': 'test-product',
|
||||
'feedback_id': feedback_id
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_users(app):
|
||||
"""Create test users (admin and product owner)"""
|
||||
users_file = os.path.join(app.config['DATA_DIR'], 'users.yaml')
|
||||
|
||||
# User model expects format: {'users': {user_id: user_data}}
|
||||
users_data = {
|
||||
'users': {
|
||||
'usr_admin': {
|
||||
'user_id': 'usr_admin',
|
||||
'username': 'admin',
|
||||
'email': 'admin@example.com',
|
||||
'password_hash': User.hash_password('admin123'),
|
||||
'role': 'administrator',
|
||||
'product_ids': [],
|
||||
'is_active': True
|
||||
},
|
||||
'usr_owner1': {
|
||||
'user_id': 'usr_owner1',
|
||||
'username': 'owner1',
|
||||
'email': 'owner1@example.com',
|
||||
'password_hash': User.hash_password('owner123'),
|
||||
'role': 'product_owner',
|
||||
'product_ids': ['test-product'],
|
||||
'is_active': True
|
||||
},
|
||||
'usr_owner2': {
|
||||
'user_id': 'usr_owner2',
|
||||
'username': 'owner2',
|
||||
'email': 'owner2@example.com',
|
||||
'password_hash': User.hash_password('owner456'),
|
||||
'role': 'product_owner',
|
||||
'product_ids': ['other-product'],
|
||||
'is_active': True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with open(users_file, 'w') as f:
|
||||
yaml.dump(users_data, f)
|
||||
|
||||
yield users_data
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_login(client):
|
||||
"""T093: Contract test for GET /login
|
||||
|
||||
Expected: 200 OK with HTML login form
|
||||
"""
|
||||
response = client.get('/login')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'<form' in response.data
|
||||
assert b'username' in response.data.lower() or b'email' in response.data.lower()
|
||||
assert b'password' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_login_valid_credentials(client, app, test_users):
|
||||
"""T094: Contract test for POST /login with valid credentials
|
||||
|
||||
Expected: 302 redirect to dashboard with session established
|
||||
"""
|
||||
data = {
|
||||
'username': 'admin',
|
||||
'password': 'admin123'
|
||||
}
|
||||
|
||||
response = client.post('/login', data=data, follow_redirects=False)
|
||||
|
||||
# Should redirect (302) to dashboard or home
|
||||
assert response.status_code == 302
|
||||
|
||||
# Follow redirect and verify user is logged in
|
||||
response_redirected = client.get(response.location, follow_redirects=True)
|
||||
assert response_redirected.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_login_invalid_credentials(client, app, test_users):
|
||||
"""T095: Contract test for POST /login with invalid credentials (401)
|
||||
|
||||
Expected: 401 Unauthorized or redirect back to login with error message
|
||||
"""
|
||||
data = {
|
||||
'username': 'admin',
|
||||
'password': 'wrongpassword'
|
||||
}
|
||||
|
||||
response = client.post('/login', data=data)
|
||||
|
||||
# Should return error (401 or 200 with error message)
|
||||
assert response.status_code in [200, 401]
|
||||
|
||||
if response.status_code == 200:
|
||||
# If returns 200, should show error message
|
||||
assert b'invalid' in response.data.lower() or b'incorrect' in response.data.lower() or b'error' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_logout(client, app, test_users):
|
||||
"""T096: Contract test for GET /logout
|
||||
|
||||
Expected: 302 redirect to login or home, session cleared
|
||||
"""
|
||||
# First login
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
# Then logout
|
||||
response = client.get('/logout', follow_redirects=False)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_dashboard_authenticated(client, app, test_users, test_product):
|
||||
"""T097: Contract test for GET /dashboard (authenticated)
|
||||
|
||||
Expected: 200 OK with dashboard showing feedback list
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
response = client.get('/dashboard')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'feedback' in response.data.lower() or b'dashboard' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_dashboard_unauthenticated(client):
|
||||
"""T098: Contract test for GET /dashboard (unauthenticated redirect)
|
||||
|
||||
Expected: 302 redirect to login page
|
||||
"""
|
||||
response = client.get('/dashboard', follow_redirects=False)
|
||||
|
||||
# Should redirect to login
|
||||
assert response.status_code == 302
|
||||
assert '/login' in response.location
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_dashboard_with_filters(client, app, test_users, test_product):
|
||||
"""T099: Contract test for GET /dashboard with filters
|
||||
|
||||
Expected: 200 OK with filtered feedback list
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
# Request with filters
|
||||
response = client.get('/dashboard?category=bug&status=new')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_dashboard_with_search(client, app, test_users, test_product):
|
||||
"""T100: Contract test for GET /dashboard with search query
|
||||
|
||||
Expected: 200 OK with search results
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
# Request with search query
|
||||
response = client.get('/dashboard?search=test')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_feedback_detail(client, app, test_users, test_product):
|
||||
"""T101: Contract test for GET /feedback/{id} detail view
|
||||
|
||||
Expected: 200 OK with feedback detail page showing content, metadata, attachments
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
feedback_id = test_product['feedback_id']
|
||||
response = client.get(f'/feedback/{feedback_id}')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'Test feedback content' in response.data or b'feedback' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_feedback_status_update(client, app, test_users, test_product):
|
||||
"""T102: Contract test for POST /feedback/{id}/status update
|
||||
|
||||
Expected: 200/302 success, metadata.yaml updated with new status
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
feedback_id = test_product['feedback_id']
|
||||
|
||||
data = {
|
||||
'status': 'in_progress'
|
||||
}
|
||||
|
||||
response = client.post(f'/feedback/{feedback_id}/status', data=data)
|
||||
|
||||
# Should succeed
|
||||
assert response.status_code in [200, 302]
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_attachment_download(client, app, test_users, test_product):
|
||||
"""T103: Contract test for GET /feedback/{id}/attachment/{filename} download
|
||||
|
||||
Expected: 200 OK with file content, correct Content-Disposition header
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
feedback_id = test_product['feedback_id']
|
||||
|
||||
response = client.get(f'/feedback/{feedback_id}/attachment/test.txt')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'test attachment content' in response.data
|
||||
# Should have download headers
|
||||
assert 'Content-Disposition' in response.headers or 'content-disposition' in response.headers
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_access_control_owner_products(client, app, test_users, test_product):
|
||||
"""T104: Contract test for access control (owner sees only assigned products)
|
||||
|
||||
Expected: Product owner can only access feedback for their assigned products
|
||||
"""
|
||||
# Login as owner1 (has access to test-product)
|
||||
client.post('/login', data={'username': 'owner1', 'password': 'owner123'})
|
||||
|
||||
feedback_id = test_product['feedback_id']
|
||||
|
||||
# Should have access to feedback from test-product
|
||||
response = client.get(f'/feedback/{feedback_id}')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Logout
|
||||
client.get('/logout')
|
||||
|
||||
# Login as owner2 (only has access to other-product)
|
||||
client.post('/login', data={'username': 'owner2', 'password': 'owner456'})
|
||||
|
||||
# Should NOT have access to feedback from test-product
|
||||
response = client.get(f'/feedback/{feedback_id}')
|
||||
assert response.status_code == 403 # Forbidden
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_trigger_analysis_success(client, app, test_users, test_product):
|
||||
"""Contract test for POST /feedback/{id}/analyze - manual analysis trigger
|
||||
|
||||
Expected: 302 redirect with success message, status updated to 'analyzed'
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
|
||||
feedback_id = test_product['feedback_id']
|
||||
|
||||
# Mock the Claude API response
|
||||
mock_api_response = Mock()
|
||||
mock_api_response.content = [Mock(text="""
|
||||
# Feedback Analysis
|
||||
|
||||
**Category**: bug
|
||||
|
||||
**Original Language**: en
|
||||
|
||||
**Summary**: User reports a test feedback issue
|
||||
|
||||
**Translation**: (same as original)
|
||||
""")]
|
||||
|
||||
# Patch AI analyzer
|
||||
with patch('app.routes.dashboard.ClaudeAnalyzer') as mock_analyzer_class:
|
||||
mock_analyzer = Mock()
|
||||
mock_analyzer.analyze_feedback.return_value = Mock(
|
||||
category='bug',
|
||||
original_language='en',
|
||||
summary='User reports a test feedback issue',
|
||||
translation='(same as original)',
|
||||
raw_analysis=mock_api_response.content[0].text
|
||||
)
|
||||
mock_analyzer_class.return_value = mock_analyzer
|
||||
|
||||
with patch('app.routes.dashboard.os.getenv', return_value='test-api-key'):
|
||||
response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=False)
|
||||
|
||||
# Should redirect
|
||||
assert response.status_code == 302
|
||||
assert f'/feedback/{feedback_id}' in response.location
|
||||
|
||||
# Verify analysis was saved
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
analysis_file = os.path.join(
|
||||
data_dir, 'products', 'test-product', 'feedback', feedback_id, 'analysis.md'
|
||||
)
|
||||
assert os.path.exists(analysis_file)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_trigger_analysis_no_content(client, app, test_users, test_product):
|
||||
"""Contract test for POST /feedback/{id}/analyze - feedback with empty text content
|
||||
|
||||
Expected: 302 redirect with error message
|
||||
"""
|
||||
# Create feedback with empty content.txt
|
||||
with app.app_context():
|
||||
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
|
||||
feedback_id = 'test-feedback-empty-text'
|
||||
feedback_path = os.path.join(product_dir, 'feedback', feedback_id)
|
||||
os.makedirs(feedback_path, exist_ok=True)
|
||||
|
||||
metadata = {
|
||||
'feedback_id': feedback_id,
|
||||
'product_id': 'test-product',
|
||||
'status': 'new',
|
||||
'submitted_at': '2025-10-16T10:00:00Z',
|
||||
'has_attachments': True,
|
||||
'attachment_count': 1
|
||||
}
|
||||
|
||||
with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f:
|
||||
yaml.dump(metadata, f)
|
||||
|
||||
# Create empty content.txt
|
||||
with open(os.path.join(feedback_path, 'content.txt'), 'w') as f:
|
||||
f.write('')
|
||||
|
||||
# Create attachments directory to show this has attachments only
|
||||
attachments_dir = os.path.join(feedback_path, 'attachments')
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
with open(os.path.join(attachments_dir, 'image.png'), 'w') as f:
|
||||
f.write('fake image data')
|
||||
|
||||
# Login and try to analyze
|
||||
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
|
||||
response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=True)
|
||||
|
||||
# Should show error message
|
||||
assert response.status_code == 200
|
||||
assert b'Cannot analyze' in response.data or b'no text content' in response.data
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_trigger_analysis_unauthenticated(client, app, test_product):
|
||||
"""Contract test for POST /feedback/{id}/analyze - unauthenticated access
|
||||
|
||||
Expected: 302 redirect to login
|
||||
"""
|
||||
feedback_id = test_product['feedback_id']
|
||||
response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=False)
|
||||
|
||||
# Should redirect to login
|
||||
assert response.status_code == 302
|
||||
assert '/login' in response.location
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Contract tests for submission routes"""
|
||||
import pytest
|
||||
import io
|
||||
import os
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_product(app):
|
||||
"""Create a test product"""
|
||||
with app.app_context():
|
||||
# Create test product directory and config
|
||||
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
|
||||
os.makedirs(product_dir, exist_ok=True)
|
||||
|
||||
# Create product config
|
||||
config_file = os.path.join(product_dir, 'config.yaml')
|
||||
config_data = {
|
||||
'product_id': 'test-product',
|
||||
'name': 'Test Product',
|
||||
'submission_url_slug': 'test-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': ['usr_0001'],
|
||||
'status': 'active'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
yield 'test-product'
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_submission_form(client, test_product):
|
||||
"""T030: Contract test for GET /submit/{product_slug}
|
||||
|
||||
Expected: 200 OK with HTML form containing textarea and file inputs
|
||||
"""
|
||||
response = client.get('/submit/test-product')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'<form' in response.data
|
||||
assert b'textarea' in response.data or b'<textarea' in response.data
|
||||
assert b'type="file"' in response.data
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_submission_text_only(client, test_product):
|
||||
"""T031: Contract test for POST /submit/{product_slug} with text only
|
||||
|
||||
Expected: 200/302 success with confirmation message
|
||||
"""
|
||||
data = {
|
||||
'feedback_text': 'This is my feedback about the product.'
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product', data=data, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_submission_files_only(client, test_product):
|
||||
"""T032: Contract test for POST /submit/{product_slug} with files only
|
||||
|
||||
Expected: 200/302 success with confirmation message
|
||||
"""
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(b'test file content'), 'test.txt')
|
||||
]
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_post_submission_text_and_files(client, test_product):
|
||||
"""T033: Contract test for POST /submit/{product_slug} with text and files
|
||||
|
||||
Expected: 200/302 success with confirmation message
|
||||
"""
|
||||
data = {
|
||||
'feedback_text': 'Here is my feedback with attachments.',
|
||||
'files': [
|
||||
(io.BytesIO(b'screenshot content'), 'screenshot.png'),
|
||||
(io.BytesIO(b'log file content'), 'error.log')
|
||||
]
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_empty_submission_rejected(client, test_product):
|
||||
"""T034: Contract test for empty submission rejection (400)
|
||||
|
||||
Expected: 400 Bad Request - must provide either text or files
|
||||
"""
|
||||
data = {
|
||||
'feedback_text': ''
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product', data=data)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_too_many_files_rejected(client, test_product):
|
||||
"""T035: Contract test for >3 files rejection (400)
|
||||
|
||||
Expected: 400 Bad Request - maximum 3 files allowed
|
||||
"""
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(b'file1'), 'file1.txt'),
|
||||
(io.BytesIO(b'file2'), 'file2.txt'),
|
||||
(io.BytesIO(b'file3'), 'file3.txt'),
|
||||
(io.BytesIO(b'file4'), 'file4.txt')
|
||||
]
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
content_type='multipart/form-data')
|
||||
|
||||
assert response.status_code == 400
|
||||
assert b'maximum' in response.data.lower() or b'3' in response.data
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_large_file_rejected(client, test_product):
|
||||
"""T036: Contract test for >10MB file rejection (413)
|
||||
|
||||
Expected: 413 Request Entity Too Large
|
||||
"""
|
||||
# Create a file larger than 10MB
|
||||
large_content = b'x' * (11 * 1024 * 1024) # 11MB
|
||||
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(large_content), 'large.txt')
|
||||
]
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
content_type='multipart/form-data')
|
||||
|
||||
# Flask will reject this with 413 due to MAX_CONTENT_LENGTH
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_unsupported_file_type_rejected(client, test_product):
|
||||
"""T037: Contract test for unsupported file type rejection (400)
|
||||
|
||||
Expected: 400 Bad Request - file type not allowed
|
||||
"""
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(b'#!/bin/bash\necho malicious'), 'script.sh')
|
||||
]
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
content_type='multipart/form-data')
|
||||
|
||||
assert response.status_code == 400
|
||||
assert b'not allowed' in response.data.lower() or b'type' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_rate_limiting(client, test_product, app):
|
||||
"""T038: Contract test for rate limiting (429 after 10 submissions)
|
||||
|
||||
Expected: 429 Too Many Requests after exceeding rate limit
|
||||
"""
|
||||
# Skip if rate limiting is disabled
|
||||
if not app.config.get('RATELIMIT_ENABLED'):
|
||||
pytest.skip('Rate limiting disabled in test config')
|
||||
|
||||
# Make 10 successful submissions (the limit)
|
||||
for i in range(10):
|
||||
data = {'feedback_text': f'Feedback {i}'}
|
||||
response = client.post('/submit/test-product', data=data)
|
||||
# Should succeed (200 or 302)
|
||||
assert response.status_code in [200, 302]
|
||||
|
||||
# 11th submission should be rate limited
|
||||
data = {'feedback_text': 'This should be rate limited'}
|
||||
response = client.post('/submit/test-product', data=data)
|
||||
|
||||
assert response.status_code == 429
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests package"""
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Integration test for AI-powered feedback analysis flow"""
|
||||
import pytest
|
||||
import os
|
||||
import yaml
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_product_for_analysis(app):
|
||||
"""Create a test product for analysis testing"""
|
||||
with app.app_context():
|
||||
# Create test product directory and config
|
||||
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'analysis-test-product')
|
||||
os.makedirs(product_dir, exist_ok=True)
|
||||
|
||||
# Create product config
|
||||
config_file = os.path.join(product_dir, 'config.yaml')
|
||||
config_data = {
|
||||
'product_id': 'analysis-test-product',
|
||||
'name': 'Analysis Test Product',
|
||||
'submission_url_slug': 'analysis-test',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': ['usr_0001'],
|
||||
'status': 'active'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
yield 'analysis-test-product'
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_full_ai_analysis_flow(client, app, test_product_for_analysis):
|
||||
"""T070: Integration test for full AI analysis flow
|
||||
|
||||
Test the complete AI analysis workflow:
|
||||
1. User submits feedback in German
|
||||
2. System saves feedback to filesystem
|
||||
3. Background analysis task is triggered
|
||||
4. AI analyzes feedback (category, summary, translation)
|
||||
5. Analysis.md is created with results
|
||||
6. Metadata is updated with status and language
|
||||
7. Original content.txt is preserved
|
||||
"""
|
||||
# Mock the Claude API response
|
||||
mock_api_response = Mock()
|
||||
mock_api_response.content = [Mock(text="""
|
||||
# Feedback Analysis
|
||||
|
||||
**Category**: bug
|
||||
|
||||
**Original Language**: de
|
||||
|
||||
**Summary**: User reports that the login button is not working on mobile devices
|
||||
|
||||
**Translation**: The login button on mobile devices does not respond when I click it. I tried multiple times but nothing happens.
|
||||
""")]
|
||||
|
||||
# Patch the AI analyzer to use mock response
|
||||
with patch('app.services.ai_analyzer.anthropic.Anthropic'):
|
||||
with patch('app.services.ai_analyzer.ClaudeAnalyzer._call_claude_api', return_value=mock_api_response):
|
||||
with patch('os.getenv', return_value='test-api-key'):
|
||||
# Step 1-2: Submit feedback in German
|
||||
feedback_text = "Der Login-Button auf mobilen Geräten reagiert nicht, wenn ich darauf klicke. Ich habe es mehrmals versucht, aber es passiert nichts."
|
||||
|
||||
data = {
|
||||
'feedback_text': feedback_text
|
||||
}
|
||||
|
||||
# Also mock the background threading to run synchronously in tests
|
||||
with patch('app.routes.submission.threading.Thread') as mock_thread:
|
||||
# Make the thread run immediately in the test with proper args
|
||||
def run_sync():
|
||||
target = mock_thread.call_args[1]['target']
|
||||
args = mock_thread.call_args[1]['args']
|
||||
# Call with app context - first arg is app instance
|
||||
with args[0].app_context():
|
||||
target(*args)
|
||||
|
||||
mock_thread.return_value.start.side_effect = run_sync
|
||||
|
||||
response = client.post('/submit/analysis-test',
|
||||
data=data,
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Step 6-7: Verify feedback was saved and analyzed
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
products_dir = os.path.join(data_dir, 'products', 'analysis-test-product', 'feedback')
|
||||
|
||||
# Find the created feedback directory
|
||||
feedback_dirs = [d for d in os.listdir(products_dir)
|
||||
if os.path.isdir(os.path.join(products_dir, d))]
|
||||
|
||||
assert len(feedback_dirs) > 0, "No feedback directory was created"
|
||||
|
||||
feedback_dir = os.path.join(products_dir, feedback_dirs[0])
|
||||
|
||||
# Verify original content.txt is preserved (FR-016)
|
||||
content_file = os.path.join(feedback_dir, 'content.txt')
|
||||
assert os.path.exists(content_file)
|
||||
|
||||
with open(content_file, 'r', encoding='utf-8') as f:
|
||||
saved_content = f.read()
|
||||
|
||||
assert feedback_text in saved_content, "Original content not preserved"
|
||||
|
||||
# Verify analysis.md was created
|
||||
analysis_file = os.path.join(feedback_dir, 'analysis.md')
|
||||
assert os.path.exists(analysis_file), "Analysis file not created"
|
||||
|
||||
with open(analysis_file, 'r', encoding='utf-8') as f:
|
||||
analysis_content = f.read()
|
||||
|
||||
# Verify analysis contains expected sections
|
||||
assert '# Feedback Analysis' in analysis_content
|
||||
assert 'Category' in analysis_content
|
||||
assert 'bug' in analysis_content
|
||||
assert 'Original Language' in analysis_content
|
||||
assert 'de' in analysis_content
|
||||
assert 'Summary' in analysis_content
|
||||
assert 'Translation' in analysis_content
|
||||
|
||||
# Verify metadata was updated
|
||||
metadata_file = os.path.join(feedback_dir, 'metadata.yaml')
|
||||
assert os.path.exists(metadata_file)
|
||||
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
# Status should be 'analyzed' after successful analysis
|
||||
assert metadata['status'] in ['analyzed', 'analyzing']
|
||||
# Original language should be detected and stored
|
||||
assert metadata.get('original_language') == 'de'
|
||||
# Category should be stored
|
||||
assert metadata.get('category') == 'bug'
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_analysis_preserves_images(client, app, test_product_for_analysis):
|
||||
"""Integration test: Verify images are stored but not analyzed via OCR (FR-021)
|
||||
|
||||
Per FR-021, images should be stored as attachments but not processed for OCR.
|
||||
Only text content should be analyzed.
|
||||
"""
|
||||
import io
|
||||
|
||||
feedback_text = "Screenshot of the error"
|
||||
|
||||
data = {
|
||||
'feedback_text': feedback_text,
|
||||
'files': [
|
||||
(io.BytesIO(b'PNG fake image data'), 'screenshot.png')
|
||||
]
|
||||
}
|
||||
|
||||
# Mock AI to ensure it only receives text, not image data
|
||||
mock_api_response = Mock()
|
||||
mock_api_response.content = [Mock(text="""
|
||||
# Feedback Analysis
|
||||
|
||||
**Category**: bug
|
||||
|
||||
**Original Language**: en
|
||||
|
||||
**Summary**: User provided screenshot of error
|
||||
|
||||
**Translation**: (same as original)
|
||||
""")]
|
||||
|
||||
with patch('app.services.ai_analyzer.anthropic.Anthropic'):
|
||||
with patch('app.services.ai_analyzer.ClaudeAnalyzer._call_claude_api', return_value=mock_api_response) as mock_call:
|
||||
with patch('os.getenv', return_value='test-api-key'):
|
||||
with patch('app.routes.submission.threading.Thread') as mock_thread:
|
||||
# Make the thread run immediately in the test with proper args
|
||||
def run_sync():
|
||||
target = mock_thread.call_args[1]['target']
|
||||
args = mock_thread.call_args[1]['args']
|
||||
# Call with app context - first arg is app instance
|
||||
with args[0].app_context():
|
||||
target(*args)
|
||||
|
||||
mock_thread.return_value.start.side_effect = run_sync
|
||||
|
||||
response = client.post('/submit/analysis-test',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify AI was called with text only, not image data
|
||||
if mock_call.called:
|
||||
call_args = str(mock_call.call_args)
|
||||
# Should contain text feedback
|
||||
assert 'Screenshot of the error' in call_args or 'screenshot' in call_args.lower()
|
||||
# Should NOT contain image binary data
|
||||
assert b'PNG' not in call_args.encode() if isinstance(call_args, str) else b'PNG' not in call_args
|
||||
|
||||
# Verify image was stored as attachment
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
products_dir = os.path.join(data_dir, 'products', 'analysis-test-product', 'feedback')
|
||||
|
||||
feedback_dirs = [d for d in os.listdir(products_dir)
|
||||
if os.path.isdir(os.path.join(products_dir, d))]
|
||||
|
||||
feedback_dir = os.path.join(products_dir, feedback_dirs[0])
|
||||
attachments_dir = os.path.join(feedback_dir, 'attachments')
|
||||
|
||||
assert os.path.exists(attachments_dir)
|
||||
assert 'screenshot.png' in os.listdir(attachments_dir)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Integration test for complete dashboard access flow"""
|
||||
import pytest
|
||||
import os
|
||||
import yaml
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_product_with_feedback(app):
|
||||
"""Create a test product with multiple feedback items"""
|
||||
with app.app_context():
|
||||
# Create test product directory and config
|
||||
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'dashboard-test-product')
|
||||
os.makedirs(product_dir, exist_ok=True)
|
||||
|
||||
# Create product config
|
||||
config_file = os.path.join(product_dir, 'config.yaml')
|
||||
config_data = {
|
||||
'product_id': 'dashboard-test-product',
|
||||
'name': 'Dashboard Test Product',
|
||||
'submission_url_slug': 'dashboard-test-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': ['usr_dashboard_owner'],
|
||||
'status': 'active'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
# Create feedback directory
|
||||
feedback_dir = os.path.join(product_dir, 'feedback')
|
||||
os.makedirs(feedback_dir, exist_ok=True)
|
||||
|
||||
# Create multiple test feedback items
|
||||
feedback_items = [
|
||||
{
|
||||
'id': 'feedback-bug-001',
|
||||
'category': 'bug',
|
||||
'status': 'new',
|
||||
'content': 'Found a critical bug in the login system',
|
||||
'has_attachments': True,
|
||||
'attachment': 'bug-screenshot.png'
|
||||
},
|
||||
{
|
||||
'id': 'feedback-feature-001',
|
||||
'category': 'feature_request',
|
||||
'status': 'new',
|
||||
'content': 'Please add dark mode to the application',
|
||||
'has_attachments': False,
|
||||
'attachment': None
|
||||
},
|
||||
{
|
||||
'id': 'feedback-bug-002',
|
||||
'category': 'bug',
|
||||
'status': 'in_progress',
|
||||
'content': 'Error when uploading large files',
|
||||
'has_attachments': True,
|
||||
'attachment': 'error.log'
|
||||
}
|
||||
]
|
||||
|
||||
for item in feedback_items:
|
||||
feedback_path = os.path.join(feedback_dir, item['id'])
|
||||
os.makedirs(feedback_path, exist_ok=True)
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
'feedback_id': item['id'],
|
||||
'product_id': 'dashboard-test-product',
|
||||
'status': item['status'],
|
||||
'submitted_at': '2025-10-16T10:00:00Z',
|
||||
'has_attachments': item['has_attachments'],
|
||||
'attachment_count': 1 if item['has_attachments'] else 0,
|
||||
'category': item['category'],
|
||||
'original_language': 'en'
|
||||
}
|
||||
|
||||
with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f:
|
||||
yaml.dump(metadata, f)
|
||||
|
||||
# Create content
|
||||
with open(os.path.join(feedback_path, 'content.txt'), 'w') as f:
|
||||
f.write(item['content'])
|
||||
|
||||
# Create attachment if needed
|
||||
if item['has_attachments']:
|
||||
attachments_dir = os.path.join(feedback_path, 'attachments')
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
|
||||
with open(os.path.join(attachments_dir, item['attachment']), 'w') as f:
|
||||
f.write(f'Attachment content for {item["id"]}')
|
||||
|
||||
yield {
|
||||
'product_id': 'dashboard-test-product',
|
||||
'feedback_items': feedback_items
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_test_users(app):
|
||||
"""Create test users for dashboard testing"""
|
||||
users_file = os.path.join(app.config['DATA_DIR'], 'users.yaml')
|
||||
|
||||
# User model expects format: {'users': {user_id: user_data}}
|
||||
users_data = {
|
||||
'users': {
|
||||
'usr_dashboard_owner': {
|
||||
'user_id': 'usr_dashboard_owner',
|
||||
'username': 'dashboard_owner',
|
||||
'email': 'dashboard@example.com',
|
||||
'password_hash': User.hash_password('dashboard123'),
|
||||
'role': 'product_owner',
|
||||
'product_ids': ['dashboard-test-product'],
|
||||
'is_active': True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with open(users_file, 'w') as f:
|
||||
yaml.dump(users_data, f)
|
||||
|
||||
yield users_data
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_complete_dashboard_access_flow(client, app, test_product_with_feedback, dashboard_test_users):
|
||||
"""T105: Integration test for complete dashboard access flow
|
||||
|
||||
Test the entire product owner journey:
|
||||
1. Owner logs in with credentials
|
||||
2. Owner views dashboard with feedback list
|
||||
3. Owner filters feedback by category
|
||||
4. Owner searches for specific feedback
|
||||
5. Owner views feedback detail
|
||||
6. Owner downloads attachment
|
||||
7. Owner updates feedback status
|
||||
8. Owner logs out
|
||||
"""
|
||||
# Step 1: Login as product owner
|
||||
login_response = client.post('/login', data={
|
||||
'username': 'dashboard_owner',
|
||||
'password': 'dashboard123'
|
||||
}, follow_redirects=False)
|
||||
|
||||
assert login_response.status_code == 302 # Redirect after successful login
|
||||
|
||||
# Step 2: View dashboard with feedback list
|
||||
dashboard_response = client.get('/dashboard')
|
||||
assert dashboard_response.status_code == 200
|
||||
assert b'feedback' in dashboard_response.data.lower() or b'dashboard' in dashboard_response.data.lower()
|
||||
|
||||
# Verify feedback items are shown
|
||||
# (At least should show some feedback indicators)
|
||||
|
||||
# Step 3: Filter feedback by category (bug)
|
||||
filter_response = client.get('/dashboard?category=bug')
|
||||
assert filter_response.status_code == 200
|
||||
|
||||
# Step 4: Search for specific feedback
|
||||
search_response = client.get('/dashboard?search=login')
|
||||
assert search_response.status_code == 200
|
||||
|
||||
# Step 5: View feedback detail
|
||||
feedback_id = test_product_with_feedback['feedback_items'][0]['id']
|
||||
detail_response = client.get(f'/feedback/{feedback_id}')
|
||||
|
||||
assert detail_response.status_code == 200
|
||||
# Should show the feedback content
|
||||
assert b'Found a critical bug in the login system' in detail_response.data or b'feedback' in detail_response.data.lower()
|
||||
|
||||
# Step 6: Download attachment
|
||||
attachment_response = client.get(f'/feedback/{feedback_id}/attachment/bug-screenshot.png')
|
||||
|
||||
assert attachment_response.status_code == 200
|
||||
assert b'Attachment content' in attachment_response.data
|
||||
# Should have download headers
|
||||
assert 'Content-Disposition' in attachment_response.headers or 'content-disposition' in attachment_response.headers
|
||||
|
||||
# Step 7: Update feedback status
|
||||
status_update_response = client.post(f'/feedback/{feedback_id}/status', data={
|
||||
'status': 'in_progress'
|
||||
}, follow_redirects=False)
|
||||
|
||||
assert status_update_response.status_code in [200, 302]
|
||||
|
||||
# Verify status was updated in filesystem
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
metadata_file = os.path.join(
|
||||
data_dir,
|
||||
'products',
|
||||
'dashboard-test-product',
|
||||
'feedback',
|
||||
feedback_id,
|
||||
'metadata.yaml'
|
||||
)
|
||||
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
assert metadata['status'] == 'in_progress'
|
||||
|
||||
# Step 8: Logout
|
||||
logout_response = client.get('/logout', follow_redirects=False)
|
||||
assert logout_response.status_code == 302
|
||||
|
||||
# Verify user is logged out (accessing dashboard should redirect to login)
|
||||
protected_response = client.get('/dashboard', follow_redirects=False)
|
||||
assert protected_response.status_code == 302
|
||||
assert '/login' in protected_response.location
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dashboard_access_control_enforcement(client, app, test_product_with_feedback, dashboard_test_users):
|
||||
"""Integration test for access control - owner can only see assigned products"""
|
||||
# Create another product owner with different product access
|
||||
users_file = os.path.join(app.config['DATA_DIR'], 'users.yaml')
|
||||
|
||||
with open(users_file, 'r') as f:
|
||||
users_data = yaml.safe_load(f)
|
||||
|
||||
# Add new user to the users dict
|
||||
users_data['users']['usr_other_owner'] = {
|
||||
'user_id': 'usr_other_owner',
|
||||
'username': 'other_owner',
|
||||
'email': 'other@example.com',
|
||||
'password_hash': User.hash_password('other123'),
|
||||
'role': 'product_owner',
|
||||
'product_ids': ['different-product'],
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
with open(users_file, 'w') as f:
|
||||
yaml.dump(users_data, f)
|
||||
|
||||
# Login as owner without access to dashboard-test-product
|
||||
client.post('/login', data={
|
||||
'username': 'other_owner',
|
||||
'password': 'other123'
|
||||
})
|
||||
|
||||
# Try to access feedback from product they don't own
|
||||
feedback_id = test_product_with_feedback['feedback_items'][0]['id']
|
||||
response = client.get(f'/feedback/{feedback_id}')
|
||||
|
||||
# Should be denied access (403 Forbidden)
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Integration test for complete feedback submission flow"""
|
||||
import pytest
|
||||
import io
|
||||
import os
|
||||
import yaml
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_product(app):
|
||||
"""Create a test product"""
|
||||
with app.app_context():
|
||||
# Create test product directory and config
|
||||
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
|
||||
os.makedirs(product_dir, exist_ok=True)
|
||||
|
||||
# Create product config
|
||||
config_file = os.path.join(product_dir, 'config.yaml')
|
||||
config_data = {
|
||||
'product_id': 'test-product',
|
||||
'name': 'Test Product',
|
||||
'submission_url_slug': 'test-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': ['usr_0001'],
|
||||
'status': 'active'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
yield 'test-product'
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_complete_feedback_submission_flow(client, app, test_product):
|
||||
"""T039: Integration test for complete feedback submission flow
|
||||
|
||||
Test the entire user journey:
|
||||
1. User visits submission form
|
||||
2. User fills in feedback text
|
||||
3. User attaches files
|
||||
4. User submits form
|
||||
5. System validates input
|
||||
6. System saves feedback to filesystem
|
||||
7. System displays confirmation
|
||||
8. Feedback is retrievable from storage
|
||||
"""
|
||||
# Step 1: Visit submission form
|
||||
response = client.get('/submit/test-product')
|
||||
assert response.status_code == 200
|
||||
assert b'<form' in response.data
|
||||
|
||||
# Step 2-4: Submit feedback with text and files
|
||||
feedback_text = 'I found a bug in the login page. When I enter my password, it does not accept special characters.'
|
||||
|
||||
data = {
|
||||
'feedback_text': feedback_text,
|
||||
'files': [
|
||||
(io.BytesIO(b'PNG fake image data'), 'screenshot.png'),
|
||||
(io.BytesIO(b'Error log contents\nLine 2\nLine 3'), 'error.log')
|
||||
]
|
||||
}
|
||||
|
||||
# Mock threading to prevent background analysis (keep original Phase 3 behavior)
|
||||
with patch('app.routes.submission.threading.Thread'):
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True)
|
||||
|
||||
# Step 7: Verify success confirmation
|
||||
assert response.status_code == 200
|
||||
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
||||
|
||||
# Step 8: Verify feedback was saved to filesystem
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
products_dir = os.path.join(data_dir, 'products', 'test-product', 'feedback')
|
||||
|
||||
# Check that feedback directory was created
|
||||
assert os.path.exists(products_dir)
|
||||
|
||||
# Find the created feedback directory (should be UUID-named)
|
||||
feedback_dirs = [d for d in os.listdir(products_dir)
|
||||
if os.path.isdir(os.path.join(products_dir, d))]
|
||||
|
||||
assert len(feedback_dirs) > 0, "No feedback directory was created"
|
||||
|
||||
feedback_dir = os.path.join(products_dir, feedback_dirs[0])
|
||||
|
||||
# Verify metadata.yaml exists
|
||||
metadata_file = os.path.join(feedback_dir, 'metadata.yaml')
|
||||
assert os.path.exists(metadata_file)
|
||||
|
||||
# Verify metadata content
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
assert metadata['feedback_id'] == feedback_dirs[0]
|
||||
assert metadata['product_id'] == 'test-product'
|
||||
assert metadata['status'] == 'new'
|
||||
assert 'submitted_at' in metadata
|
||||
assert metadata.get('has_attachments') == True
|
||||
assert metadata.get('attachment_count') == 2
|
||||
|
||||
# Verify content.txt exists and contains the feedback
|
||||
content_file = os.path.join(feedback_dir, 'content.txt')
|
||||
assert os.path.exists(content_file)
|
||||
|
||||
with open(content_file, 'r') as f:
|
||||
saved_content = f.read()
|
||||
|
||||
assert feedback_text in saved_content
|
||||
|
||||
# Verify attachments directory and files exist
|
||||
attachments_dir = os.path.join(feedback_dir, 'attachments')
|
||||
assert os.path.exists(attachments_dir)
|
||||
|
||||
attachments = os.listdir(attachments_dir)
|
||||
assert len(attachments) == 2
|
||||
|
||||
# Verify specific attachment files
|
||||
attachment_names = [a for a in attachments]
|
||||
assert 'screenshot.png' in attachment_names
|
||||
assert 'error.log' in attachment_names
|
||||
|
||||
# Verify no IP address is stored (FR-055 compliance)
|
||||
assert 'ip_address' not in metadata
|
||||
assert 'submitter_ip' not in metadata
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_feedback_submission_without_attachments(client, app, test_product):
|
||||
"""Integration test for feedback submission with text only (no files)"""
|
||||
feedback_text = 'Simple text feedback without attachments.'
|
||||
|
||||
data = {
|
||||
'feedback_text': feedback_text
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify feedback was saved
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
products_dir = os.path.join(data_dir, 'products', 'test-product', 'feedback')
|
||||
|
||||
feedback_dirs = [d for d in os.listdir(products_dir)
|
||||
if os.path.isdir(os.path.join(products_dir, d))]
|
||||
|
||||
# Find the most recent feedback
|
||||
feedback_dir = os.path.join(products_dir, feedback_dirs[-1])
|
||||
|
||||
# Verify metadata shows no attachments
|
||||
metadata_file = os.path.join(feedback_dir, 'metadata.yaml')
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
assert metadata.get('has_attachments') == False
|
||||
assert metadata.get('attachment_count') == 0
|
||||
|
||||
# Verify attachments directory doesn't exist or is empty
|
||||
attachments_dir = os.path.join(feedback_dir, 'attachments')
|
||||
if os.path.exists(attachments_dir):
|
||||
assert len(os.listdir(attachments_dir)) == 0
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Performance tests for Reklamator
|
||||
|
||||
Tests performance requirements from spec.md:
|
||||
- SC-012: System handles 100 concurrent feedback submissions
|
||||
- SC-008: Dashboard loads 1000 feedback items in less than 3 seconds
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import pytest
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from app.services.feedback_storage import FeedbackStorageService
|
||||
from app.models.product import Product
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_concurrent_submissions(client, temp_data_dir, sample_product):
|
||||
"""Test handling 100 concurrent submissions (SC-012)
|
||||
|
||||
This test verifies the system can handle high concurrent load
|
||||
without errors or data corruption.
|
||||
"""
|
||||
product_slug = sample_product.submission_url_slug
|
||||
num_submissions = 100
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
submission_times = []
|
||||
|
||||
def submit_feedback(thread_id):
|
||||
"""Submit a single feedback item"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
response = client.post(
|
||||
f'/submit/{product_slug}',
|
||||
data={
|
||||
'feedback_text': f'Concurrent test feedback #{thread_id}',
|
||||
'csrf_token': 'test_csrf_token'
|
||||
},
|
||||
follow_redirects=False
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
return response.status_code, elapsed
|
||||
except Exception as e:
|
||||
print(f"Error in thread {thread_id}: {e}")
|
||||
return 500, 0
|
||||
|
||||
# Execute concurrent submissions
|
||||
start_time = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=20) as executor:
|
||||
futures = [executor.submit(submit_feedback, i) for i in range(num_submissions)]
|
||||
|
||||
for future in as_completed(futures):
|
||||
status_code, elapsed = future.result()
|
||||
submission_times.append(elapsed)
|
||||
|
||||
if status_code in [200, 302]: # Success or redirect
|
||||
success_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
total_time = time.time() - start_time
|
||||
|
||||
# Calculate statistics
|
||||
avg_time = sum(submission_times) / len(submission_times)
|
||||
max_time = max(submission_times)
|
||||
min_time = min(submission_times)
|
||||
|
||||
print(f"\n=== Concurrent Submission Test Results ===")
|
||||
print(f"Total submissions: {num_submissions}")
|
||||
print(f"Successful: {success_count}")
|
||||
print(f"Failed: {error_count}")
|
||||
print(f"Total time: {total_time:.2f}s")
|
||||
print(f"Throughput: {num_submissions / total_time:.2f} submissions/second")
|
||||
print(f"Average response time: {avg_time:.3f}s")
|
||||
print(f"Min response time: {min_time:.3f}s")
|
||||
print(f"Max response time: {max_time:.3f}s")
|
||||
|
||||
# Assertions
|
||||
assert success_count >= 95, f"Too many failures: {error_count} out of {num_submissions}"
|
||||
assert avg_time < 5.0, f"Average response time too high: {avg_time:.2f}s"
|
||||
|
||||
# Verify data integrity - check that submissions were actually saved
|
||||
feedback_dir = os.path.join(temp_data_dir, 'products', sample_product.product_id, 'feedback')
|
||||
if os.path.exists(feedback_dir):
|
||||
saved_count = len([d for d in os.listdir(feedback_dir)
|
||||
if os.path.isdir(os.path.join(feedback_dir, d))])
|
||||
print(f"Feedback items saved: {saved_count}")
|
||||
assert saved_count >= 95, f"Not all submissions were saved: {saved_count} out of {num_submissions}"
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_dashboard_load_performance(client, temp_data_dir, sample_product, auth_user):
|
||||
"""Test dashboard loads 1000 items in <3 seconds (SC-008)
|
||||
|
||||
This test creates 1000 feedback items and measures dashboard load time.
|
||||
"""
|
||||
# Login first
|
||||
client.post('/login', data={
|
||||
'username': auth_user.username,
|
||||
'password': 'admin123',
|
||||
'csrf_token': 'test_csrf_token'
|
||||
})
|
||||
|
||||
# Create 1000 feedback items
|
||||
print("\n=== Creating 1000 feedback items for performance test ===")
|
||||
create_start = time.time()
|
||||
|
||||
for i in range(1000):
|
||||
FeedbackStorageService.save_complete_feedback(
|
||||
product_id=sample_product.product_id,
|
||||
content_text=f"Performance test feedback #{i}",
|
||||
files=None
|
||||
)
|
||||
|
||||
if (i + 1) % 100 == 0:
|
||||
print(f"Created {i + 1} items...")
|
||||
|
||||
create_time = time.time() - create_start
|
||||
print(f"Creation completed in {create_time:.2f}s")
|
||||
|
||||
# Measure dashboard load time (cold load - first request)
|
||||
print("\n=== Testing dashboard load time ===")
|
||||
start_time = time.time()
|
||||
response = client.get('/dashboard')
|
||||
cold_load_time = time.time() - start_time
|
||||
|
||||
assert response.status_code == 200
|
||||
print(f"Cold load time (first request): {cold_load_time:.3f}s")
|
||||
|
||||
# Measure warm load time (subsequent requests)
|
||||
warm_times = []
|
||||
for i in range(3):
|
||||
start_time = time.time()
|
||||
response = client.get('/dashboard')
|
||||
elapsed = time.time() - start_time
|
||||
warm_times.append(elapsed)
|
||||
print(f"Warm load time (request {i+2}): {elapsed:.3f}s")
|
||||
|
||||
avg_warm_time = sum(warm_times) / len(warm_times)
|
||||
print(f"Average warm load time: {avg_warm_time:.3f}s")
|
||||
|
||||
# Test with pagination (page 2)
|
||||
start_time = time.time()
|
||||
response = client.get('/dashboard?page=2')
|
||||
page2_time = time.time() - start_time
|
||||
print(f"Page 2 load time: {page2_time:.3f}s")
|
||||
|
||||
# Test with filters
|
||||
start_time = time.time()
|
||||
response = client.get('/dashboard?status=new')
|
||||
filter_time = time.time() - start_time
|
||||
print(f"Filtered view load time: {filter_time:.3f}s")
|
||||
|
||||
# Assertions - SC-008 requires <3 seconds for 1000 items
|
||||
assert cold_load_time < 3.0, f"Dashboard load time exceeds 3s: {cold_load_time:.2f}s"
|
||||
assert avg_warm_time < 3.0, f"Average warm load time exceeds 3s: {avg_warm_time:.2f}s"
|
||||
assert page2_time < 3.0, f"Page 2 load time exceeds 3s: {page2_time:.2f}s"
|
||||
assert filter_time < 3.0, f"Filtered view load time exceeds 3s: {filter_time:.2f}s"
|
||||
|
||||
print(f"\n✓ All dashboard performance tests passed!")
|
||||
print(f"✓ Cold load: {cold_load_time:.3f}s < 3.0s")
|
||||
print(f"✓ Warm load: {avg_warm_time:.3f}s < 3.0s")
|
||||
print(f"✓ Pagination: {page2_time:.3f}s < 3.0s")
|
||||
print(f"✓ Filtering: {filter_time:.3f}s < 3.0s")
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_large_file_upload_performance(client, sample_product):
|
||||
"""Test performance with maximum size file uploads
|
||||
|
||||
Verifies system can handle 3x10MB files without timeout.
|
||||
"""
|
||||
product_slug = sample_product.submission_url_slug
|
||||
|
||||
# Create 3 files of 10MB each (at the limit)
|
||||
file_size = 10 * 1024 * 1024 # 10MB
|
||||
files = []
|
||||
|
||||
for i in range(3):
|
||||
file_data = b'x' * file_size
|
||||
files.append(
|
||||
(BytesIO(file_data), f'large_file_{i}.txt')
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
response = client.post(
|
||||
f'/submit/{product_slug}',
|
||||
data={
|
||||
'feedback_text': 'Testing large file upload performance',
|
||||
'files': files,
|
||||
'csrf_token': 'test_csrf_token'
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=False
|
||||
)
|
||||
|
||||
upload_time = time.time() - start_time
|
||||
|
||||
print(f"\n=== Large File Upload Test ===")
|
||||
print(f"Total size: {3 * file_size / (1024*1024):.1f}MB")
|
||||
print(f"Upload time: {upload_time:.2f}s")
|
||||
print(f"Upload speed: {(3 * file_size / (1024*1024)) / upload_time:.2f}MB/s")
|
||||
|
||||
# Should complete within reasonable time (30s for 30MB)
|
||||
assert upload_time < 30.0, f"Upload took too long: {upload_time:.2f}s"
|
||||
assert response.status_code in [200, 302], f"Upload failed with status {response.status_code}"
|
||||
|
||||
print(f"✓ Large file upload completed successfully in {upload_time:.2f}s")
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_rapid_sequential_submissions(client, sample_product):
|
||||
"""Test rapid sequential submissions from single client
|
||||
|
||||
Verifies rate limiting works correctly.
|
||||
"""
|
||||
product_slug = sample_product.submission_url_slug
|
||||
num_submissions = 15 # More than the rate limit (10/hour)
|
||||
|
||||
success_count = 0
|
||||
rate_limited_count = 0
|
||||
|
||||
print(f"\n=== Rapid Sequential Submission Test ===")
|
||||
|
||||
for i in range(num_submissions):
|
||||
response = client.post(
|
||||
f'/submit/{product_slug}',
|
||||
data={
|
||||
'feedback_text': f'Rapid submission #{i}',
|
||||
'csrf_token': 'test_csrf_token'
|
||||
},
|
||||
follow_redirects=False
|
||||
)
|
||||
|
||||
if response.status_code in [200, 302]:
|
||||
success_count += 1
|
||||
elif response.status_code == 429: # Too Many Requests
|
||||
rate_limited_count += 1
|
||||
print(f"Rate limited at submission {i + 1}")
|
||||
|
||||
print(f"Successful submissions: {success_count}")
|
||||
print(f"Rate limited: {rate_limited_count}")
|
||||
|
||||
# Should allow at least the configured number (10) but then start rate limiting
|
||||
# Note: In testing, rate limiting might be disabled
|
||||
assert success_count > 0, "No submissions succeeded"
|
||||
print(f"✓ Rate limiting test completed (success: {success_count}, limited: {rate_limited_count})")
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests package"""
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Unit tests for AI analyzer"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from app.services.ai_analyzer import AIAnalyzer, ClaudeAnalyzer
|
||||
from app.models.feedback import AnalysisResult
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ai_analyzer_interface():
|
||||
"""T065: Unit test for AIAnalyzer interface
|
||||
|
||||
Verify that AIAnalyzer is an abstract base class
|
||||
that cannot be instantiated directly
|
||||
"""
|
||||
with pytest.raises(TypeError):
|
||||
# Should not be able to instantiate abstract base class
|
||||
AIAnalyzer()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_claude_analyzer_categorization(app):
|
||||
"""T066: Unit test for ClaudeAnalyzer categorization
|
||||
|
||||
Verify that ClaudeAnalyzer correctly extracts category from AI response
|
||||
"""
|
||||
with app.app_context():
|
||||
# Mock the Anthropic client to avoid initialization issues
|
||||
with patch('app.services.ai_analyzer.anthropic.Anthropic'):
|
||||
analyzer = ClaudeAnalyzer(api_key='test-key')
|
||||
|
||||
# Mock AI response with category
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="""
|
||||
# Feedback Analysis
|
||||
|
||||
**Category**: bug
|
||||
|
||||
**Original Language**: en
|
||||
|
||||
**Summary**: User reports login issue
|
||||
|
||||
**Translation**: (same as original)
|
||||
""")]
|
||||
|
||||
with patch.object(analyzer, '_call_claude_api', return_value=mock_response):
|
||||
result = analyzer.analyze_feedback(
|
||||
feedback_text="Login button doesn't work",
|
||||
target_language='en',
|
||||
product_id='test-product'
|
||||
)
|
||||
|
||||
assert result.category == 'bug'
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_claude_analyzer_translation(app):
|
||||
"""T067: Unit test for ClaudeAnalyzer translation
|
||||
|
||||
Verify that ClaudeAnalyzer correctly translates feedback
|
||||
"""
|
||||
with app.app_context():
|
||||
# Mock the Anthropic client to avoid initialization issues
|
||||
with patch('app.services.ai_analyzer.anthropic.Anthropic'):
|
||||
analyzer = ClaudeAnalyzer(api_key='test-key')
|
||||
|
||||
# Mock AI response with translation
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="""
|
||||
# Feedback Analysis
|
||||
|
||||
**Category**: feature_request
|
||||
|
||||
**Original Language**: de
|
||||
|
||||
**Summary**: User wants dark mode
|
||||
|
||||
**Translation**: I would like to have a dark mode for the application
|
||||
""")]
|
||||
|
||||
with patch.object(analyzer, '_call_claude_api', return_value=mock_response):
|
||||
result = analyzer.analyze_feedback(
|
||||
feedback_text="Ich hätte gerne einen Dark Mode für die Anwendung",
|
||||
target_language='en',
|
||||
product_id='test-product'
|
||||
)
|
||||
|
||||
assert result.translation == 'I would like to have a dark mode for the application'
|
||||
assert result.original_language == 'de'
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_claude_analyzer_summary_generation(app):
|
||||
"""T068: Unit test for ClaudeAnalyzer summary generation
|
||||
|
||||
Verify that ClaudeAnalyzer generates concise summaries
|
||||
"""
|
||||
with app.app_context():
|
||||
# Mock the Anthropic client to avoid initialization issues
|
||||
with patch('app.services.ai_analyzer.anthropic.Anthropic'):
|
||||
analyzer = ClaudeAnalyzer(api_key='test-key')
|
||||
|
||||
# Mock AI response with summary
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="""
|
||||
# Feedback Analysis
|
||||
|
||||
**Category**: complaint
|
||||
|
||||
**Original Language**: en
|
||||
|
||||
**Summary**: User experienced slow page load times during peak hours
|
||||
|
||||
**Translation**: (same as original)
|
||||
""")]
|
||||
|
||||
with patch.object(analyzer, '_call_claude_api', return_value=mock_response):
|
||||
long_feedback = """
|
||||
I've been using your service for three months now, and I have to say
|
||||
I'm quite disappointed with the performance during peak hours. Yesterday
|
||||
evening around 8 PM, I tried to load the dashboard multiple times and
|
||||
each time it took over 30 seconds. This is unacceptable for a paid service.
|
||||
"""
|
||||
|
||||
result = analyzer.analyze_feedback(
|
||||
feedback_text=long_feedback,
|
||||
target_language='en',
|
||||
product_id='test-product'
|
||||
)
|
||||
|
||||
assert result.summary == 'User experienced slow page load times during peak hours'
|
||||
assert len(result.summary) < len(long_feedback)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_analysis_error_handling(app):
|
||||
"""T069: Unit test for analysis error handling
|
||||
|
||||
Verify that ClaudeAnalyzer handles API errors gracefully
|
||||
"""
|
||||
with app.app_context():
|
||||
# Mock the Anthropic client to avoid initialization issues
|
||||
with patch('app.services.ai_analyzer.anthropic.Anthropic'):
|
||||
analyzer = ClaudeAnalyzer(api_key='test-key')
|
||||
|
||||
# Test API timeout
|
||||
with patch.object(analyzer, '_call_claude_api', side_effect=TimeoutError("API timeout")):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
analyzer.analyze_feedback(
|
||||
feedback_text="Test feedback",
|
||||
target_language='en',
|
||||
product_id='test-product'
|
||||
)
|
||||
assert "timeout" in str(exc_info.value).lower() or "API" in str(exc_info.value)
|
||||
|
||||
# Test API error
|
||||
with patch.object(analyzer, '_call_claude_api', side_effect=Exception("API Error")):
|
||||
with pytest.raises(Exception):
|
||||
analyzer.analyze_feedback(
|
||||
feedback_text="Test feedback",
|
||||
target_language='en',
|
||||
product_id='test-product'
|
||||
)
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Unit tests for User model"""
|
||||
import pytest
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_creation(app):
|
||||
"""Test User model instantiation"""
|
||||
with app.app_context():
|
||||
user = User(
|
||||
user_id='usr_test',
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
password_hash='hash123',
|
||||
role='administrator',
|
||||
product_ids=['prod_001'],
|
||||
is_active=True
|
||||
)
|
||||
|
||||
assert user.user_id == 'usr_test'
|
||||
assert user.username == 'testuser'
|
||||
assert user.email == 'test@example.com'
|
||||
assert user.password_hash == 'hash123'
|
||||
assert user.role == 'administrator'
|
||||
assert user.product_ids == ['prod_001']
|
||||
assert user.is_active == True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_is_active_property(app):
|
||||
"""Test is_active property (Flask-Login integration)
|
||||
|
||||
Bug: AttributeError: can't set attribute 'is_active'
|
||||
Fix: Use private _is_active attribute with property decorator
|
||||
"""
|
||||
with app.app_context():
|
||||
# Test active user
|
||||
active_user = User(
|
||||
user_id='usr_001',
|
||||
username='active',
|
||||
email='active@example.com',
|
||||
password_hash='hash',
|
||||
role='administrator',
|
||||
is_active=True
|
||||
)
|
||||
assert active_user.is_active == True
|
||||
|
||||
# Test inactive user
|
||||
inactive_user = User(
|
||||
user_id='usr_002',
|
||||
username='inactive',
|
||||
email='inactive@example.com',
|
||||
password_hash='hash',
|
||||
role='administrator',
|
||||
is_active=False
|
||||
)
|
||||
assert inactive_user.is_active == False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_flask_login_properties(app):
|
||||
"""Test Flask-Login required properties"""
|
||||
with app.app_context():
|
||||
user = User(
|
||||
user_id='usr_001',
|
||||
username='test',
|
||||
email='test@example.com',
|
||||
password_hash='hash',
|
||||
role='administrator'
|
||||
)
|
||||
|
||||
# Flask-Login required properties
|
||||
assert user.is_authenticated == True
|
||||
assert user.is_anonymous == False
|
||||
assert user.is_active == True
|
||||
assert user.get_id() == 'usr_001'
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_password_hashing(app):
|
||||
"""Test password hashing with bcrypt"""
|
||||
with app.app_context():
|
||||
password = 'test_password_123'
|
||||
hashed = User.hash_password(password)
|
||||
|
||||
# Hash should be different from plain password
|
||||
assert hashed != password
|
||||
|
||||
# Hash should be bcrypt format
|
||||
assert hashed.startswith('$2b$')
|
||||
|
||||
# Same password should produce different hashes (salt)
|
||||
hashed2 = User.hash_password(password)
|
||||
assert hashed != hashed2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_password_verification(app):
|
||||
"""Test password verification"""
|
||||
with app.app_context():
|
||||
password = 'correct_password'
|
||||
wrong_password = 'wrong_password'
|
||||
|
||||
user = User(
|
||||
user_id='usr_001',
|
||||
username='test',
|
||||
email='test@example.com',
|
||||
password_hash=User.hash_password(password),
|
||||
role='administrator'
|
||||
)
|
||||
|
||||
# Correct password should verify
|
||||
assert user.check_password(password) == True
|
||||
|
||||
# Wrong password should not verify
|
||||
assert user.check_password(wrong_password) == False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_to_dict(app):
|
||||
"""Test user serialization to dictionary"""
|
||||
with app.app_context():
|
||||
user = User(
|
||||
user_id='usr_001',
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
password_hash='hash123',
|
||||
role='product_owner',
|
||||
product_ids=['prod_001', 'prod_002'],
|
||||
is_active=True
|
||||
)
|
||||
|
||||
user_dict = user.to_dict()
|
||||
|
||||
assert user_dict['user_id'] == 'usr_001'
|
||||
assert user_dict['username'] == 'testuser'
|
||||
assert user_dict['email'] == 'test@example.com'
|
||||
assert user_dict['password_hash'] == 'hash123'
|
||||
assert user_dict['role'] == 'product_owner'
|
||||
assert user_dict['product_ids'] == ['prod_001', 'prod_002']
|
||||
assert user_dict['is_active'] == True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_from_dict(app):
|
||||
"""Test user deserialization from dictionary"""
|
||||
with app.app_context():
|
||||
user_data = {
|
||||
'user_id': 'usr_001',
|
||||
'username': 'testuser',
|
||||
'email': 'test@example.com',
|
||||
'password_hash': 'hash123',
|
||||
'role': 'administrator',
|
||||
'product_ids': ['prod_001'],
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
user = User.from_dict(user_data)
|
||||
|
||||
assert user.user_id == 'usr_001'
|
||||
assert user.username == 'testuser'
|
||||
assert user.email == 'test@example.com'
|
||||
assert user.password_hash == 'hash123'
|
||||
assert user.role == 'administrator'
|
||||
assert user.product_ids == ['prod_001']
|
||||
assert user.is_active == True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_from_dict_with_defaults(app):
|
||||
"""Test user deserialization with missing optional fields"""
|
||||
with app.app_context():
|
||||
user_data = {
|
||||
'user_id': 'usr_001',
|
||||
'username': 'testuser',
|
||||
'email': 'test@example.com',
|
||||
'password_hash': 'hash123',
|
||||
'role': 'administrator'
|
||||
# Missing product_ids and is_active
|
||||
}
|
||||
|
||||
user = User.from_dict(user_data)
|
||||
|
||||
assert user.product_ids == [] # Default empty list
|
||||
assert user.is_active == True # Default True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_create(app):
|
||||
"""Test user creation with auto-generated ID"""
|
||||
with app.app_context():
|
||||
user = User.create(
|
||||
username='newuser',
|
||||
email='new@example.com',
|
||||
password='password123',
|
||||
role='product_owner',
|
||||
product_ids=['prod_001']
|
||||
)
|
||||
|
||||
# User should be created
|
||||
assert user.user_id.startswith('usr_')
|
||||
assert user.username == 'newuser'
|
||||
assert user.email == 'new@example.com'
|
||||
assert user.role == 'product_owner'
|
||||
assert user.product_ids == ['prod_001']
|
||||
assert user.is_active == True
|
||||
|
||||
# Password should be hashed
|
||||
assert user.password_hash != 'password123'
|
||||
assert user.check_password('password123') == True
|
||||
|
||||
# Cleanup
|
||||
user.delete()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_create_duplicate_username(app):
|
||||
"""Test user creation with duplicate username raises error"""
|
||||
with app.app_context():
|
||||
# Create first user
|
||||
user1 = User.create(
|
||||
username='duplicate',
|
||||
email='user1@example.com',
|
||||
password='password123',
|
||||
role='administrator'
|
||||
)
|
||||
|
||||
# Try to create second user with same username
|
||||
with pytest.raises(ValueError, match="Username already exists"):
|
||||
User.create(
|
||||
username='duplicate',
|
||||
email='user2@example.com',
|
||||
password='password456',
|
||||
role='administrator'
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
user1.delete()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_create_invalid_role(app):
|
||||
"""Test user creation with invalid role raises error"""
|
||||
with app.app_context():
|
||||
with pytest.raises(ValueError, match="Invalid role"):
|
||||
User.create(
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
password='password123',
|
||||
role='invalid_role'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_save_and_load(app):
|
||||
"""Test user persistence (save and load)"""
|
||||
with app.app_context():
|
||||
# Create and save user
|
||||
user = User.create(
|
||||
username='persistent',
|
||||
email='persist@example.com',
|
||||
password='password123',
|
||||
role='administrator'
|
||||
)
|
||||
user_id = user.user_id
|
||||
|
||||
# Load user from storage
|
||||
loaded_user = User.get_by_id(user_id)
|
||||
|
||||
assert loaded_user is not None
|
||||
assert loaded_user.user_id == user_id
|
||||
assert loaded_user.username == 'persistent'
|
||||
assert loaded_user.email == 'persist@example.com'
|
||||
assert loaded_user.role == 'administrator'
|
||||
assert loaded_user.check_password('password123') == True
|
||||
|
||||
# Cleanup
|
||||
user.delete()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_get_by_username(app):
|
||||
"""Test loading user by username"""
|
||||
with app.app_context():
|
||||
# Create user
|
||||
user = User.create(
|
||||
username='findme',
|
||||
email='findme@example.com',
|
||||
password='password123',
|
||||
role='product_owner'
|
||||
)
|
||||
|
||||
# Find by username
|
||||
found_user = User.get_by_username('findme')
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user.username == 'findme'
|
||||
assert found_user.email == 'findme@example.com'
|
||||
|
||||
# Non-existent username
|
||||
not_found = User.get_by_username('doesnotexist')
|
||||
assert not_found is None
|
||||
|
||||
# Cleanup
|
||||
user.delete()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_get_all(app):
|
||||
"""Test getting all users"""
|
||||
with app.app_context():
|
||||
# Create multiple users
|
||||
user1 = User.create('user1', 'user1@example.com', 'pass1', 'administrator')
|
||||
user2 = User.create('user2', 'user2@example.com', 'pass2', 'product_owner')
|
||||
|
||||
# Get all users
|
||||
all_users = User.get_all()
|
||||
|
||||
# Should include at least our test users
|
||||
usernames = [u.username for u in all_users]
|
||||
assert 'user1' in usernames
|
||||
assert 'user2' in usernames
|
||||
|
||||
# Cleanup
|
||||
user1.delete()
|
||||
user2.delete()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_delete(app):
|
||||
"""Test user deletion"""
|
||||
with app.app_context():
|
||||
# Create user
|
||||
user = User.create(
|
||||
username='deleteme',
|
||||
email='delete@example.com',
|
||||
password='password123',
|
||||
role='administrator'
|
||||
)
|
||||
user_id = user.user_id
|
||||
|
||||
# Delete user
|
||||
user.delete()
|
||||
|
||||
# User should no longer exist
|
||||
deleted_user = User.get_by_id(user_id)
|
||||
assert deleted_user is None
|
||||
Reference in New Issue
Block a user