muhammadnoman76 commited on
Commit
31b9617
·
1 Parent(s): ee4aa06
Files changed (47) hide show
  1. .gitattributes +2 -0
  2. .gitignore +29 -0
  3. Dockerfile +57 -0
  4. Makefile +37 -0
  5. README-Docker.md +46 -0
  6. docker-compose.yml +15 -0
  7. metsa-backend/.env.example +25 -0
  8. metsa-backend/PROJECT_DOCUMENTATION.md +1695 -0
  9. metsa-backend/README.md +0 -0
  10. metsa-backend/app/__init__.py +0 -0
  11. metsa-backend/app/config.py +36 -0
  12. metsa-backend/app/database.py +18 -0
  13. metsa-backend/app/main.py +107 -0
  14. metsa-backend/app/middleware/__init__.py +0 -0
  15. metsa-backend/app/middleware/auth.py +0 -0
  16. metsa-backend/app/models/__init__.py +0 -0
  17. metsa-backend/app/models/document.py +40 -0
  18. metsa-backend/app/models/notification.py +23 -0
  19. metsa-backend/app/models/user.py +68 -0
  20. metsa-backend/app/routers/__init__.py +0 -0
  21. metsa-backend/app/routers/auth.py +65 -0
  22. metsa-backend/app/routers/documents.py +396 -0
  23. metsa-backend/app/routers/notifications.py +62 -0
  24. metsa-backend/app/routers/users.py +159 -0
  25. metsa-backend/app/schemas/__init__.py +0 -0
  26. metsa-backend/app/schemas/document.py +34 -0
  27. metsa-backend/app/schemas/notification.py +19 -0
  28. metsa-backend/app/schemas/user.py +47 -0
  29. metsa-backend/app/services/__init__.py +0 -0
  30. metsa-backend/app/services/auth.py +35 -0
  31. metsa-backend/app/services/document.py +158 -0
  32. metsa-backend/app/services/email.py +53 -0
  33. metsa-backend/app/services/notification.py +52 -0
  34. metsa-backend/app/services/user.py +110 -0
  35. metsa-backend/app/utils/__init__.py +0 -0
  36. metsa-backend/app/utils/permissions.py +54 -0
  37. metsa-backend/app/utils/security.py +49 -0
  38. metsa-backend/documentation.py +137 -0
  39. metsa-backend/requirements.txt +13 -0
  40. metsa-backend/uploads/commercial/20250818_165924_Predicting Chronic Heart Failure After Myocardial Infarction Using Machine Learning Techniques (updated).docx +3 -0
  41. metsa-backend/uploads/compliance/20250718_181330_c4611_sample_explain.pdf +3 -0
  42. metsa-backend/uploads/other/20250818_190139_MidTerm Exam Schedule Fall-2024.pdf +3 -0
  43. metsa-backend/uploads/other/20250818_210114_FYP_Report_Updated (1).docx +3 -0
  44. metsa-backend/uploads/other/20250818_210611_Paper+5+598+25-4-24-P-27-32.pdf +3 -0
  45. metsa-frontend +1 -0
  46. scripts/build_frontend.sh +9 -0
  47. scripts/start.sh +18 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.docx filter=lfs diff=lfs merge=lfs -text
37
+ *.pdf filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node.js
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+ .pnpm-debug.log*
7
+
8
+ # Python
9
+ __pycache__/
10
+ *.py[cod]
11
+ *.pyo
12
+ *.pyd
13
+ *.pkl
14
+ *.pyo
15
+ *.egg-info/
16
+ *.egg
17
+ *.manifest
18
+ *.spec
19
+
20
+ # Environments
21
+ .env
22
+ .venv/
23
+ venv/
24
+ ENV/
25
+
26
+ # Build & cache
27
+ dist/
28
+ build/
29
+ *.log
Dockerfile ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage Dockerfile to run both frontend (Next.js) and backend (FastAPI) in a single container
2
+
3
+ # 1) Build the Next.js frontend as static export
4
+ FROM node:20-bullseye AS frontend-builder
5
+ WORKDIR /app/frontend
6
+ ENV NEXT_TELEMETRY_DISABLED=1
7
+
8
+ # Install deps first (better cache)
9
+ COPY metsa-frontend/package*.json ./
10
+ RUN npm ci
11
+
12
+ # Copy source and build + export
13
+ COPY metsa-frontend/ .
14
+ RUN npm run build && npm run export
15
+
16
+ # 2) Final runtime: Node + Python in one image
17
+ FROM node:20-bullseye AS runtime
18
+ WORKDIR /app
19
+ ENV NODE_ENV=production
20
+ ENV NEXT_TELEMETRY_DISABLED=1
21
+
22
+ # Install Python and build tools required by some Python packages
23
+ RUN apt-get update \
24
+ && apt-get install -y --no-install-recommends \
25
+ python3 python3-pip python3-venv \
26
+ build-essential python3-dev libffi-dev libssl-dev \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ # ---- Backend setup ----
30
+ # Copy backend code and install Python deps into a venv
31
+ COPY metsa-backend/ ./metsa-backend/
32
+ RUN python3 -m venv /app/venv \
33
+ && . /app/venv/bin/activate \
34
+ && pip install --upgrade pip \
35
+ && pip install --no-cache-dir -r metsa-backend/requirements.txt
36
+
37
+ # Ensure uploads directory exists
38
+ RUN mkdir -p metsa-backend/uploads
39
+
40
+ # ---- Frontend setup ----
41
+ # Copy Next.js exported static site into backend-served directory
42
+ WORKDIR /app
43
+ COPY --from=frontend-builder /app/frontend/out ./metsa-frontend/out
44
+
45
+ # ---- App startup ----
46
+ WORKDIR /app
47
+ COPY scripts/start.sh ./scripts/start.sh
48
+ RUN chmod +x ./scripts/start.sh
49
+
50
+ # Default port (single port)
51
+ EXPOSE 8000
52
+
53
+ # API URL used by frontend static site
54
+ ENV NEXT_PUBLIC_API_URL="http://localhost:8000/api/v1"
55
+
56
+ CMD ["/bin/bash", "/app/scripts/start.sh"]
57
+
Makefile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cross-platform Makefile (Linux/macOS and Windows via Git Bash/WSL)
2
+
3
+ .DEFAULT_GOAL := help
4
+
5
+ .PHONY: help build up down logs sh clean
6
+
7
+ help:
8
+ @echo "Available targets:"
9
+ @echo " build - Build the Docker image"
10
+ @echo " up - Start app with docker-compose (frontend + backend)"
11
+ @echo " down - Stop containers"
12
+ @echo " logs - Tail logs"
13
+ @echo " sh - Shell into running container"
14
+ @echo " clean - Remove image and dangling artifacts"
15
+
16
+ build:
17
+ docker compose build
18
+
19
+ up:
20
+ docker compose up -d
21
+
22
+ Down:
23
+ docker compose down
24
+
25
+ down:
26
+ docker compose down
27
+
28
+ logs:
29
+ docker compose logs -f --tail=200
30
+
31
+ sh:
32
+ docker exec -it metsa-app /bin/bash
33
+
34
+ clean:
35
+ docker compose down -v --remove-orphans || true
36
+ docker image prune -f || true
37
+
README-Docker.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Metsa App - Docker Setup
2
+
3
+ This repository runs the Next.js frontend and FastAPI backend together using a single container image (with docker-compose for convenience).
4
+
5
+ ## Prerequisites
6
+ - Docker 24+
7
+ - Docker Compose plugin (`docker compose`) or Docker Desktop
8
+ - Make (optional; Windows users can use Git Bash or WSL)
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ # Build image
14
+ make build
15
+ # Start
16
+ make up
17
+ # Tail logs
18
+ make logs
19
+ # Stop
20
+ make down
21
+ ```
22
+
23
+ Without Make:
24
+ ```bash
25
+ docker compose build
26
+ docker compose up -d
27
+ docker compose logs -f --tail=200
28
+ ```
29
+
30
+ App URLs:
31
+ - Frontend: http://localhost:3000
32
+ - Backend: http://localhost:8000 (FastAPI docs: http://localhost:8000/docs)
33
+
34
+ ## How it works
35
+ - Multi-stage Dockerfile builds the Next.js app, then runs both the built Next app and the FastAPI server in the final runtime image.
36
+ - A small start.sh launches the backend (Uvicorn) in background and then starts Next.js.
37
+ - docker-compose exposes ports 3000 and 8000 and mounts `metsa-backend/uploads` so files persist across restarts.
38
+
39
+ ## Customizing
40
+ - Frontend API base URL can be overridden via NEXT_PUBLIC_API_URL (defaults to http://localhost:8000/api/v1).
41
+ - For production, set proper environment variables in docker-compose.yml and secure the backend settings.
42
+
43
+ ## Notes
44
+ - This single-container approach is simple for development and small deployments. For production, consider separate services and a reverse proxy.
45
+ - Ensure MongoDB is reachable by the backend. Update `metsa-backend/app/config.py` MONGODB_URL or use an external MongoDB service/container.
46
+
docker-compose.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+ services:
3
+ app:
4
+ build: .
5
+ container_name: metsa-app
6
+ ports:
7
+ - "8000:8000"
8
+ environment:
9
+ - NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
10
+ - PYTHONDONTWRITEBYTECODE=1
11
+ - PYTHONUNBUFFERED=1
12
+ volumes:
13
+ - ./metsa-backend/uploads:/app/metsa-backend/uploads
14
+ restart: unless-stopped
15
+
metsa-backend/.env.example ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Application
2
+ APP_NAME="Document Portal API"
3
+ APP_VERSION="1.0.0"
4
+ DEBUG=True
5
+
6
+ # MongoDB
7
+ MONGODB_URL=mongodb://localhost:27017
8
+ DATABASE_NAME=document_portal
9
+
10
+ # Security
11
+ SECRET_KEY=your-secret-key-here-change-in-production
12
+ ALGORITHM=HS256
13
+ ACCESS_TOKEN_EXPIRE_MINUTES=1440
14
+
15
+ # Email
16
+ SMTP_HOST=smtp.gmail.com
17
+ SMTP_PORT=587
18
+ SMTP_USER=your-email@gmail.com
19
+ SMTP_PASSWORD=your-app-password
20
+ EMAILS_FROM_EMAIL=noreply@metsa.com
21
+ EMAILS_FROM_NAME=Metsa Document Portal
22
+
23
+ # File Upload
24
+ UPLOAD_DIR=uploads
25
+ MAX_FILE_SIZE=10485760
metsa-backend/PROJECT_DOCUMENTATION.md ADDED
@@ -0,0 +1,1695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Documentation
2
+
3
+ Generated from: C:\Users\Noman\Desktop\document-portal-backend
4
+
5
+ ---
6
+
7
+ ## README.md
8
+
9
+ ```markdown
10
+
11
+ ```
12
+
13
+ ---
14
+
15
+ ## app\__init__.py
16
+
17
+ ```python
18
+
19
+ ```
20
+
21
+ ---
22
+
23
+ ## app\config.py
24
+
25
+ ```python
26
+ from pydantic import BaseModel
27
+ from typing import Optional
28
+
29
+ class Settings(BaseModel):
30
+ # Application
31
+ APP_NAME: str = "Document Portal API"
32
+ APP_VERSION: str = "1.0.0"
33
+ DEBUG: bool = True
34
+
35
+ # MongoDB
36
+ MONGODB_URL: str = "mongodb://localhost:27017"
37
+ DATABASE_NAME: str = "document_portal"
38
+
39
+ # Security
40
+ SECRET_KEY: str = "your-secret-key-here-change-in-production"
41
+ ALGORITHM: str = "HS256"
42
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
43
+
44
+ # Email
45
+ SMTP_HOST: str = "smtp.gmail.com"
46
+ SMTP_PORT: int = 587
47
+ SMTP_USER: str = ""
48
+ SMTP_PASSWORD: str = ""
49
+ EMAILS_FROM_EMAIL: Optional[str] = None
50
+ EMAILS_FROM_NAME: Optional[str] = None
51
+
52
+ # File Upload
53
+ UPLOAD_DIR: str = "uploads"
54
+ MAX_FILE_SIZE: int = 10 * 1024 * 1024
55
+ ALLOWED_EXTENSIONS: set = {".pdf", ".doc", ".docx", ".xls", ".xlsx"}
56
+
57
+ model_config = {"env_file": ".env"}
58
+
59
+ # Create settings instance
60
+ settings = Settings()
61
+ ```
62
+
63
+ ---
64
+
65
+ ## app\database.py
66
+
67
+ ```python
68
+ from motor.motor_asyncio import AsyncIOMotorClient
69
+ from typing import Optional
70
+ from .config import settings
71
+
72
+ class Database:
73
+ client: Optional[AsyncIOMotorClient] = None
74
+
75
+ db = Database()
76
+
77
+ async def connect_to_mongo():
78
+ db.client = AsyncIOMotorClient(settings.MONGODB_URL)
79
+
80
+ async def close_mongo_connection():
81
+ if db.client:
82
+ db.client.close()
83
+
84
+ def get_database():
85
+ return db.client[settings.DATABASE_NAME]
86
+
87
+ ```
88
+
89
+ ---
90
+
91
+ ## app\main.py
92
+
93
+ ```python
94
+ from fastapi import FastAPI
95
+ from fastapi.middleware.cors import CORSMiddleware
96
+ from contextlib import asynccontextmanager
97
+ import os
98
+
99
+ from .config import settings
100
+ from .database import connect_to_mongo, close_mongo_connection
101
+ from .routers import auth, users, documents, notifications
102
+ from .services.user import UserService
103
+ from .schemas.user import UserCreate
104
+ from .models.user import UserRole
105
+
106
+ @asynccontextmanager
107
+ async def lifespan(app: FastAPI):
108
+ # Startup
109
+ await connect_to_mongo()
110
+
111
+ # Create upload directory
112
+ os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
113
+
114
+ # Create default admin user if not exists
115
+ user_service = UserService()
116
+ admin = await user_service.get_user_by_username("admin")
117
+ if not admin:
118
+ admin_data = UserCreate(
119
+ email="admin@metsa.com",
120
+ username="admin",
121
+ password="admin123", # Change this in production!
122
+ role=UserRole.ADMIN
123
+ )
124
+ await user_service.create_user(admin_data, "system")
125
+ print("Default admin user created: username=admin, password=admin123")
126
+
127
+ yield
128
+
129
+ # Shutdown
130
+ await close_mongo_connection()
131
+
132
+ app = FastAPI(
133
+ title=settings.APP_NAME,
134
+ version=settings.APP_VERSION,
135
+ lifespan=lifespan
136
+ )
137
+
138
+ # CORS middleware
139
+ app.add_middleware(
140
+ CORSMiddleware,
141
+ allow_origins=["*"], # Configure this properly in production
142
+ allow_credentials=True,
143
+ allow_methods=["*"],
144
+ allow_headers=["*"],
145
+ )
146
+
147
+ # Include routers
148
+ app.include_router(auth.router, prefix="/api/v1")
149
+ app.include_router(users.router, prefix="/api/v1")
150
+ app.include_router(documents.router, prefix="/api/v1")
151
+ app.include_router(notifications.router, prefix="/api/v1")
152
+
153
+ @app.get("/")
154
+ async def root():
155
+ return {
156
+ "message": "Welcome to Metsa Document Portal API",
157
+ "version": settings.APP_VERSION,
158
+ "docs": "/docs"
159
+ }
160
+
161
+ @app.get("/health")
162
+ async def health_check():
163
+ return {"status": "healthy"}
164
+
165
+ ```
166
+
167
+ ---
168
+
169
+ ## app\middleware\__init__.py
170
+
171
+ ```python
172
+
173
+ ```
174
+
175
+ ---
176
+
177
+ ## app\middleware\auth.py
178
+
179
+ ```python
180
+
181
+ ```
182
+
183
+ ---
184
+
185
+ ## app\models\__init__.py
186
+
187
+ ```python
188
+
189
+ ```
190
+
191
+ ---
192
+
193
+ ## app\models\document.py
194
+
195
+ ```python
196
+ from typing import Optional, List, Dict
197
+ from datetime import datetime
198
+ from enum import Enum
199
+ from pydantic import BaseModel, Field
200
+ from bson import ObjectId
201
+ from .user import PyObjectId
202
+
203
+ class DocumentCategory(str, Enum):
204
+ COMMERCIAL = "commercial"
205
+ QUALITY = "quality"
206
+ SAFETY = "safety"
207
+ COMPLIANCE = "compliance"
208
+ CONTRACTS = "contracts"
209
+ SPECIFICATIONS = "specifications"
210
+ OTHER = "other"
211
+
212
+ class DocumentVisibility(str, Enum):
213
+ PUBLIC = "public"
214
+ PRIVATE = "private"
215
+ INTERNAL = "internal"
216
+
217
+ class Document(BaseModel):
218
+ model_config = {"arbitrary_types_allowed": True, "populate_by_name": True}
219
+
220
+ id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
221
+ title: str
222
+ description: Optional[str] = None
223
+ category: DocumentCategory
224
+ file_path: str
225
+ file_name: str
226
+ file_size: int
227
+ mime_type: str
228
+ visibility: DocumentVisibility = DocumentVisibility.PRIVATE
229
+ is_viewable_only: bool = False
230
+ assigned_customers: List[str] = []
231
+ tags: List[str] = []
232
+ metadata: Optional[Dict] = None
233
+ uploaded_by: str
234
+ created_at: datetime = Field(default_factory=datetime.utcnow)
235
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
236
+ ```
237
+
238
+ ---
239
+
240
+ ## app\models\notification.py
241
+
242
+ ```python
243
+ from typing import Optional
244
+ from datetime import datetime
245
+ from enum import Enum
246
+ from pydantic import BaseModel, Field
247
+ from bson import ObjectId
248
+ from .user import PyObjectId
249
+
250
+ class NotificationType(str, Enum):
251
+ NEW_DOCUMENT = "new_document"
252
+ DOCUMENT_UPDATED = "document_updated"
253
+ SYSTEM = "system"
254
+
255
+ class Notification(BaseModel):
256
+ model_config = {"arbitrary_types_allowed": True, "populate_by_name": True}
257
+
258
+ id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
259
+ user_id: str
260
+ type: NotificationType
261
+ title: str
262
+ message: str
263
+ document_id: Optional[str] = None
264
+ is_read: bool = False
265
+ created_at: datetime = Field(default_factory=datetime.utcnow)
266
+ ```
267
+
268
+ ---
269
+
270
+ ## app\models\user.py
271
+
272
+ ```python
273
+ from typing import Optional, List, Dict, Any
274
+ from datetime import datetime
275
+ from enum import Enum
276
+ from pydantic import BaseModel, EmailStr, Field, field_validator
277
+ from pydantic.json_schema import JsonSchemaValue
278
+ from pydantic_core import core_schema
279
+ from bson import ObjectId
280
+
281
+ class UserRole(str, Enum):
282
+ ADMIN = "admin"
283
+ EDITOR = "editor"
284
+ CUSTOMER = "customer"
285
+
286
+ class Permission(str, Enum):
287
+ UPLOAD = "upload"
288
+ EDIT = "edit"
289
+ DELETE = "delete"
290
+ VIEW = "view"
291
+ MANAGE_USERS = "manage_users"
292
+ MANAGE_DOCUMENTS = "manage_documents"
293
+
294
+ class PyObjectId(ObjectId):
295
+ @classmethod
296
+ def __get_pydantic_core_schema__(
297
+ cls, source_type: Any, handler
298
+ ) -> core_schema.CoreSchema:
299
+ return core_schema.json_or_python_schema(
300
+ json_schema=core_schema.str_schema(),
301
+ python_schema=core_schema.union_schema([
302
+ core_schema.is_instance_schema(ObjectId),
303
+ core_schema.chain_schema([
304
+ core_schema.str_schema(),
305
+ core_schema.no_info_plain_validator_function(cls.validate),
306
+ ])
307
+ ]),
308
+ serialization=core_schema.plain_serializer_function_ser_schema(
309
+ lambda x: str(x)
310
+ ),
311
+ )
312
+
313
+ @classmethod
314
+ def validate(cls, v):
315
+ if not ObjectId.is_valid(v):
316
+ raise ValueError("Invalid ObjectId")
317
+ return ObjectId(v)
318
+
319
+ @classmethod
320
+ def __get_pydantic_json_schema__(
321
+ cls, core_schema: core_schema.CoreSchema, handler
322
+ ) -> JsonSchemaValue:
323
+ return {"type": "string"}
324
+
325
+ class User(BaseModel):
326
+ model_config = {"arbitrary_types_allowed": True, "populate_by_name": True}
327
+
328
+ id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
329
+ email: EmailStr
330
+ username: str
331
+ hashed_password: str
332
+ role: UserRole
333
+ is_active: bool = True
334
+ is_verified: bool = False
335
+ permissions: List[Permission] = []
336
+ customer_info: Optional[Dict] = None
337
+ created_at: datetime = Field(default_factory=datetime.utcnow)
338
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
339
+ created_by: Optional[str] = None
340
+ last_login: Optional[datetime] = None
341
+ ```
342
+
343
+ ---
344
+
345
+ ## app\routers\__init__.py
346
+
347
+ ```python
348
+
349
+ ```
350
+
351
+ ---
352
+
353
+ ## app\routers\auth.py
354
+
355
+ ```python
356
+ from datetime import timedelta
357
+ from fastapi import APIRouter, Depends, HTTPException, status
358
+ from fastapi.security import OAuth2PasswordRequestForm
359
+ from ..schemas.user import Token, UserLogin
360
+ from ..services.auth import authenticate_user, create_access_token
361
+ from ..services.user import UserService
362
+ from ..config import settings
363
+
364
+ router = APIRouter(prefix="/auth", tags=["authentication"])
365
+
366
+ @router.post("/login", response_model=Token)
367
+ async def login(form_data: OAuth2PasswordRequestForm = Depends()):
368
+ user = await authenticate_user(form_data.username, form_data.password)
369
+ if not user:
370
+ raise HTTPException(
371
+ status_code=status.HTTP_401_UNAUTHORIZED,
372
+ detail="Incorrect username or password",
373
+ headers={"WWW-Authenticate": "Bearer"},
374
+ )
375
+
376
+ if not user.is_active:
377
+ raise HTTPException(
378
+ status_code=status.HTTP_400_BAD_REQUEST,
379
+ detail="Inactive user"
380
+ )
381
+
382
+ # Update last login
383
+ user_service = UserService()
384
+ await user_service.update_last_login(str(user.id))
385
+
386
+ access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
387
+ access_token = create_access_token(
388
+ data={
389
+ "sub": user.username,
390
+ "user_id": str(user.id),
391
+ "role": user.role
392
+ },
393
+ expires_delta=access_token_expires
394
+ )
395
+
396
+ return {"access_token": access_token, "token_type": "bearer"}
397
+
398
+ ```
399
+
400
+ ---
401
+
402
+ ## app\routers\documents.py
403
+
404
+ ```python
405
+ from typing import List, Optional
406
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
407
+ from fastapi.responses import FileResponse
408
+ from ..schemas.document import DocumentCreate, DocumentUpdate, DocumentResponse
409
+ from ..models.user import User, Permission
410
+ from ..models.document import DocumentCategory
411
+ from ..services.document import DocumentService
412
+ from ..utils.security import get_current_active_user
413
+ from ..utils.permissions import is_staff, check_permission, can_access_document
414
+ from ..config import settings
415
+ import os
416
+
417
+ router = APIRouter(prefix="/documents", tags=["documents"])
418
+
419
+ @router.post("/", response_model=DocumentResponse)
420
+ async def upload_document(
421
+ title: str = Form(...),
422
+ description: Optional[str] = Form(None),
423
+ category: DocumentCategory = Form(...),
424
+ visibility: str = Form("private"),
425
+ is_viewable_only: bool = Form(False),
426
+ assigned_customers: Optional[str] = Form(None),
427
+ tags: Optional[str] = Form(None),
428
+ file: UploadFile = File(...),
429
+ current_user: User = Depends(get_current_active_user)
430
+ ):
431
+ """Upload a new document (Staff only)"""
432
+ check_permission(current_user, [Permission.UPLOAD])
433
+
434
+ document_service = DocumentService()
435
+
436
+ # Validate file
437
+ if not file.filename:
438
+ raise HTTPException(status_code=400, detail="No file provided")
439
+
440
+ file_extension = os.path.splitext(file.filename)[1].lower()
441
+ if file_extension not in settings.ALLOWED_EXTENSIONS:
442
+ raise HTTPException(
443
+ status_code=400,
444
+ detail=f"File type not allowed. Allowed types: {', '.join(settings.ALLOWED_EXTENSIONS)}"
445
+ )
446
+
447
+ # Read file content
448
+ content = await file.read()
449
+ if len(content) > settings.MAX_FILE_SIZE:
450
+ raise HTTPException(
451
+ status_code=400,
452
+ detail=f"File too large. Maximum size: {settings.MAX_FILE_SIZE / 1024 / 1024}MB"
453
+ )
454
+
455
+ # Save file
456
+ file_info = await document_service.save_file(content, file.filename, category)
457
+
458
+ # Parse form data
459
+ document_data = DocumentCreate(
460
+ title=title,
461
+ description=description,
462
+ category=category,
463
+ visibility=visibility,
464
+ is_viewable_only=is_viewable_only,
465
+ assigned_customers=assigned_customers.split(",") if assigned_customers else [],
466
+ tags=tags.split(",") if tags else []
467
+ )
468
+
469
+ # Create document
470
+ document = await document_service.create_document(
471
+ document_data,
472
+ file_info,
473
+ str(current_user.id)
474
+ )
475
+
476
+ return DocumentResponse(
477
+ id=str(document.id),
478
+ title=document.title,
479
+ description=document.description,
480
+ category=document.category,
481
+ visibility=document.visibility,
482
+ is_viewable_only=document.is_viewable_only,
483
+ assigned_customers=document.assigned_customers,
484
+ tags=document.tags,
485
+ file_name=document.file_name,
486
+ file_size=document.file_size,
487
+ mime_type=document.mime_type,
488
+ uploaded_by=document.uploaded_by,
489
+ created_at=document.created_at,
490
+ updated_at=document.updated_at
491
+ )
492
+
493
+ @router.get("/", response_model=List[DocumentResponse])
494
+ async def get_documents(
495
+ skip: int = 0,
496
+ limit: int = 100,
497
+ category: Optional[DocumentCategory] = None,
498
+ current_user: User = Depends(get_current_active_user)
499
+ ):
500
+ """Get all documents accessible to the user"""
501
+ document_service = DocumentService()
502
+
503
+ documents = await document_service.get_documents(
504
+ skip=skip,
505
+ limit=limit,
506
+ category=category,
507
+ user_id=str(current_user.id),
508
+ user_role=current_user.role
509
+ )
510
+
511
+ return [
512
+ DocumentResponse(
513
+ id=str(doc.id),
514
+ title=doc.title,
515
+ description=doc.description,
516
+ category=doc.category,
517
+ visibility=doc.visibility,
518
+ is_viewable_only=doc.is_viewable_only,
519
+ assigned_customers=doc.assigned_customers,
520
+ tags=doc.tags,
521
+ file_name=doc.file_name,
522
+ file_size=doc.file_size,
523
+ mime_type=doc.mime_type,
524
+ uploaded_by=doc.uploaded_by,
525
+ created_at=doc.created_at,
526
+ updated_at=doc.updated_at
527
+ ) for doc in documents
528
+ ]
529
+
530
+ @router.get("/{document_id}", response_model=DocumentResponse)
531
+ async def get_document(
532
+ document_id: str,
533
+ current_user: User = Depends(get_current_active_user)
534
+ ):
535
+ """Get document by ID"""
536
+ document_service = DocumentService()
537
+
538
+ document = await document_service.get_document_by_id(document_id)
539
+ if not document:
540
+ raise HTTPException(status_code=404, detail="Document not found")
541
+
542
+ can_access_document(current_user, document)
543
+
544
+ return DocumentResponse(
545
+ id=str(document.id),
546
+ title=document.title,
547
+ description=document.description,
548
+ category=document.category,
549
+ visibility=document.visibility,
550
+ is_viewable_only=document.is_viewable_only,
551
+ assigned_customers=document.assigned_customers,
552
+ tags=document.tags,
553
+ file_name=document.file_name,
554
+ file_size=document.file_size,
555
+ mime_type=document.mime_type,
556
+ uploaded_by=document.uploaded_by,
557
+ created_at=document.created_at,
558
+ updated_at=document.updated_at
559
+ )
560
+
561
+ @router.get("/{document_id}/download")
562
+ async def download_document(
563
+ document_id: str,
564
+ current_user: User = Depends(get_current_active_user)
565
+ ):
566
+ """Download document"""
567
+ document_service = DocumentService()
568
+
569
+ document = await document_service.get_document_by_id(document_id)
570
+ if not document:
571
+ raise HTTPException(status_code=404, detail="Document not found")
572
+
573
+ can_access_document(current_user, document)
574
+
575
+ if document.is_viewable_only and current_user.role == "customer":
576
+ raise HTTPException(status_code=403, detail="This document is view-only")
577
+
578
+ if not os.path.exists(document.file_path):
579
+ raise HTTPException(status_code=404, detail="File not found")
580
+
581
+ return FileResponse(
582
+ path=document.file_path,
583
+ filename=document.file_name,
584
+ media_type=document.mime_type
585
+ )
586
+
587
+ @router.put("/{document_id}", response_model=DocumentResponse)
588
+ async def update_document(
589
+ document_id: str,
590
+ document_update: DocumentUpdate,
591
+ current_user: User = Depends(get_current_active_user)
592
+ ):
593
+ """Update document (Staff only)"""
594
+ check_permission(current_user, [Permission.EDIT])
595
+
596
+ document_service = DocumentService()
597
+
598
+ document = await document_service.update_document(document_id, document_update)
599
+ if not document:
600
+ raise HTTPException(status_code=404, detail="Document not found")
601
+
602
+ return DocumentResponse(
603
+ id=str(document.id),
604
+ title=document.title,
605
+ description=document.description,
606
+ category=document.category,
607
+ visibility=document.visibility,
608
+ is_viewable_only=document.is_viewable_only,
609
+ assigned_customers=document.assigned_customers,
610
+ tags=document.tags,
611
+ file_name=document.file_name,
612
+ file_size=document.file_size,
613
+ mime_type=document.mime_type,
614
+ uploaded_by=document.uploaded_by,
615
+ created_at=document.created_at,
616
+ updated_at=document.updated_at
617
+ )
618
+
619
+ @router.delete("/{document_id}")
620
+ async def delete_document(
621
+ document_id: str,
622
+ current_user: User = Depends(get_current_active_user)
623
+ ):
624
+ """Delete document (Staff only)"""
625
+ check_permission(current_user, [Permission.DELETE])
626
+
627
+ document_service = DocumentService()
628
+
629
+ success = await document_service.delete_document(document_id)
630
+ if not success:
631
+ raise HTTPException(status_code=404, detail="Document not found")
632
+
633
+ return {"message": "Document deleted successfully"}
634
+ ```
635
+
636
+ ---
637
+
638
+ ## app\routers\notifications.py
639
+
640
+ ```python
641
+ from typing import List
642
+ from fastapi import APIRouter, Depends, HTTPException
643
+ from ..schemas.notification import NotificationResponse
644
+ from ..models.user import User
645
+ from ..services.notification import NotificationService
646
+ from ..utils.security import get_current_active_user
647
+
648
+ router = APIRouter(prefix="/notifications", tags=["notifications"])
649
+
650
+ @router.get("/", response_model=List[NotificationResponse])
651
+ async def get_notifications(
652
+ skip: int = 0,
653
+ limit: int = 50,
654
+ unread_only: bool = False,
655
+ current_user: User = Depends(get_current_active_user)
656
+ ):
657
+ """Get user notifications"""
658
+ notification_service = NotificationService()
659
+
660
+ notifications = await notification_service.get_user_notifications(
661
+ str(current_user.id),
662
+ skip=skip,
663
+ limit=limit,
664
+ unread_only=unread_only
665
+ )
666
+
667
+ return [
668
+ NotificationResponse(
669
+ id=str(notif.id),
670
+ type=notif.type,
671
+ title=notif.title,
672
+ message=notif.message,
673
+ document_id=notif.document_id,
674
+ user_id=notif.user_id,
675
+ is_read=notif.is_read,
676
+ created_at=notif.created_at
677
+ ) for notif in notifications
678
+ ]
679
+
680
+ @router.put("/{notification_id}/read")
681
+ async def mark_notification_as_read(
682
+ notification_id: str,
683
+ current_user: User = Depends(get_current_active_user)
684
+ ):
685
+ """Mark notification as read"""
686
+ notification_service = NotificationService()
687
+
688
+ success = await notification_service.mark_as_read(notification_id, str(current_user.id))
689
+ if not success:
690
+ raise HTTPException(status_code=404, detail="Notification not found")
691
+
692
+ return {"message": "Notification marked as read"}
693
+
694
+ @router.put("/read-all")
695
+ async def mark_all_notifications_as_read(
696
+ current_user: User = Depends(get_current_active_user)
697
+ ):
698
+ """Mark all notifications as read"""
699
+ notification_service = NotificationService()
700
+
701
+ count = await notification_service.mark_all_as_read(str(current_user.id))
702
+ return {"message": f"{count} notifications marked as read"}
703
+
704
+ ```
705
+
706
+ ---
707
+
708
+ ## app\routers\users.py
709
+
710
+ ```python
711
+ from typing import List, Optional
712
+ from fastapi import APIRouter, Depends, HTTPException, status
713
+ from ..schemas.user import UserCreate, UserUpdate, UserResponse
714
+ from ..models.user import User, UserRole
715
+ from ..services.user import UserService
716
+ from ..utils.security import get_current_active_user
717
+ from ..utils.permissions import is_admin
718
+
719
+ router = APIRouter(prefix="/users", tags=["users"])
720
+
721
+ @router.post("/", response_model=UserResponse)
722
+ async def create_user(
723
+ user_data: UserCreate,
724
+ current_user: User = Depends(get_current_active_user)
725
+ ):
726
+ """Create a new user (Admin only)"""
727
+ is_admin(current_user)
728
+
729
+ user_service = UserService()
730
+
731
+ try:
732
+ user = await user_service.create_user(user_data, str(current_user.id))
733
+ return UserResponse(
734
+ id=str(user.id),
735
+ email=user.email,
736
+ username=user.username,
737
+ role=user.role,
738
+ permissions=user.permissions,
739
+ customer_info=user.customer_info,
740
+ is_active=user.is_active,
741
+ is_verified=user.is_verified,
742
+ created_at=user.created_at,
743
+ updated_at=user.updated_at,
744
+ last_login=user.last_login
745
+ )
746
+ except ValueError as e:
747
+ raise HTTPException(status_code=400, detail=str(e))
748
+
749
+ @router.get("/me", response_model=UserResponse)
750
+ async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
751
+ """Get current user information"""
752
+ return UserResponse(
753
+ id=str(current_user.id),
754
+ email=current_user.email,
755
+ username=current_user.username,
756
+ role=current_user.role,
757
+ permissions=current_user.permissions,
758
+ customer_info=current_user.customer_info,
759
+ is_active=current_user.is_active,
760
+ is_verified=current_user.is_verified,
761
+ created_at=current_user.created_at,
762
+ updated_at=current_user.updated_at,
763
+ last_login=current_user.last_login
764
+ )
765
+
766
+ @router.get("/", response_model=List[UserResponse])
767
+ async def get_users(
768
+ skip: int = 0,
769
+ limit: int = 100,
770
+ role: Optional[UserRole] = None,
771
+ current_user: User = Depends(get_current_active_user)
772
+ ):
773
+ """Get all users (Admin only)"""
774
+ is_admin(current_user)
775
+
776
+ user_service = UserService()
777
+
778
+ users = await user_service.get_users(skip=skip, limit=limit, role=role)
779
+ return [
780
+ UserResponse(
781
+ id=str(user.id),
782
+ email=user.email,
783
+ username=user.username,
784
+ role=user.role,
785
+ permissions=user.permissions,
786
+ customer_info=user.customer_info,
787
+ is_active=user.is_active,
788
+ is_verified=user.is_verified,
789
+ created_at=user.created_at,
790
+ updated_at=user.updated_at,
791
+ last_login=user.last_login
792
+ ) for user in users
793
+ ]
794
+
795
+ @router.get("/{user_id}", response_model=UserResponse)
796
+ async def get_user(
797
+ user_id: str,
798
+ current_user: User = Depends(get_current_active_user)
799
+ ):
800
+ """Get user by ID (Admin only)"""
801
+ is_admin(current_user)
802
+
803
+ user_service = UserService()
804
+
805
+ user = await user_service.get_user_by_id(user_id)
806
+ if not user:
807
+ raise HTTPException(status_code=404, detail="User not found")
808
+
809
+ return UserResponse(
810
+ id=str(user.id),
811
+ email=user.email,
812
+ username=user.username,
813
+ role=user.role,
814
+ permissions=user.permissions,
815
+ customer_info=user.customer_info,
816
+ is_active=user.is_active,
817
+ is_verified=user.is_verified,
818
+ created_at=user.created_at,
819
+ updated_at=user.updated_at,
820
+ last_login=user.last_login
821
+ )
822
+
823
+ @router.put("/{user_id}", response_model=UserResponse)
824
+ async def update_user(
825
+ user_id: str,
826
+ user_update: UserUpdate,
827
+ current_user: User = Depends(get_current_active_user)
828
+ ):
829
+ """Update user (Admin only)"""
830
+ is_admin(current_user)
831
+
832
+ user_service = UserService()
833
+
834
+ user = await user_service.update_user(user_id, user_update)
835
+ if not user:
836
+ raise HTTPException(status_code=404, detail="User not found")
837
+
838
+ return UserResponse(
839
+ id=str(user.id),
840
+ email=user.email,
841
+ username=user.username,
842
+ role=user.role,
843
+ permissions=user.permissions,
844
+ customer_info=user.customer_info,
845
+ is_active=user.is_active,
846
+ is_verified=user.is_verified,
847
+ created_at=user.created_at,
848
+ updated_at=user.updated_at,
849
+ last_login=user.last_login
850
+ )
851
+
852
+ @router.delete("/{user_id}")
853
+ async def delete_user(
854
+ user_id: str,
855
+ current_user: User = Depends(get_current_active_user)
856
+ ):
857
+ """Delete user (Admin only)"""
858
+ is_admin(current_user)
859
+
860
+ if str(current_user.id) == user_id:
861
+ raise HTTPException(status_code=400, detail="Cannot delete yourself")
862
+
863
+ user_service = UserService()
864
+
865
+ success = await user_service.delete_user(user_id)
866
+ if not success:
867
+ raise HTTPException(status_code=404, detail="User not found")
868
+
869
+ return {"message": "User deleted successfully"}
870
+
871
+ ```
872
+
873
+ ---
874
+
875
+ ## app\schemas\__init__.py
876
+
877
+ ```python
878
+
879
+ ```
880
+
881
+ ---
882
+
883
+ ## app\schemas\document.py
884
+
885
+ ```python
886
+ from typing import Optional, List
887
+ from datetime import datetime
888
+ from pydantic import BaseModel
889
+ from ..models.document import DocumentCategory, DocumentVisibility
890
+
891
+ class DocumentBase(BaseModel):
892
+ title: str
893
+ description: Optional[str] = None
894
+ category: DocumentCategory
895
+ visibility: DocumentVisibility = DocumentVisibility.PRIVATE
896
+ is_viewable_only: bool = False
897
+ assigned_customers: List[str] = []
898
+ tags: List[str] = []
899
+
900
+ class DocumentCreate(DocumentBase):
901
+ pass
902
+
903
+ class DocumentUpdate(BaseModel):
904
+ title: Optional[str] = None
905
+ description: Optional[str] = None
906
+ category: Optional[DocumentCategory] = None
907
+ visibility: Optional[DocumentVisibility] = None
908
+ is_viewable_only: Optional[bool] = None
909
+ assigned_customers: Optional[List[str]] = None
910
+ tags: Optional[List[str]] = None
911
+
912
+ class DocumentResponse(DocumentBase):
913
+ id: str
914
+ file_name: str
915
+ file_size: int
916
+ mime_type: str
917
+ uploaded_by: str
918
+ created_at: datetime
919
+ updated_at: datetime
920
+
921
+ ```
922
+
923
+ ---
924
+
925
+ ## app\schemas\notification.py
926
+
927
+ ```python
928
+ from typing import Optional
929
+ from datetime import datetime
930
+ from pydantic import BaseModel
931
+ from ..models.notification import NotificationType
932
+
933
+ class NotificationBase(BaseModel):
934
+ type: NotificationType
935
+ title: str
936
+ message: str
937
+ document_id: Optional[str] = None
938
+
939
+ class NotificationCreate(NotificationBase):
940
+ user_id: str
941
+
942
+ class NotificationResponse(NotificationBase):
943
+ id: str
944
+ user_id: str
945
+ is_read: bool
946
+ created_at: datetime
947
+
948
+ ```
949
+
950
+ ---
951
+
952
+ ## app\schemas\user.py
953
+
954
+ ```python
955
+ from typing import Optional, List
956
+ from datetime import datetime
957
+ from pydantic import BaseModel, EmailStr
958
+ from ..models.user import UserRole, Permission
959
+
960
+ class UserBase(BaseModel):
961
+ email: EmailStr
962
+ username: str
963
+ role: UserRole
964
+ permissions: List[Permission] = []
965
+ customer_info: Optional[dict] = None
966
+
967
+ class UserCreate(UserBase):
968
+ password: str
969
+
970
+ class UserUpdate(BaseModel):
971
+ email: Optional[EmailStr] = None
972
+ username: Optional[str] = None
973
+ role: Optional[UserRole] = None
974
+ permissions: Optional[List[Permission]] = None
975
+ is_active: Optional[bool] = None
976
+ customer_info: Optional[dict] = None
977
+
978
+ class UserResponse(UserBase):
979
+ id: str
980
+ is_active: bool
981
+ is_verified: bool
982
+ created_at: datetime
983
+ updated_at: datetime
984
+ last_login: Optional[datetime] = None
985
+
986
+ class UserLogin(BaseModel):
987
+ username: str
988
+ password: str
989
+
990
+ class Token(BaseModel):
991
+ access_token: str
992
+ token_type: str = "bearer"
993
+
994
+ class TokenData(BaseModel):
995
+ username: Optional[str] = None
996
+ user_id: Optional[str] = None
997
+ role: Optional[str] = None
998
+
999
+ ```
1000
+
1001
+ ---
1002
+
1003
+ ## app\services\__init__.py
1004
+
1005
+ ```python
1006
+
1007
+ ```
1008
+
1009
+ ---
1010
+
1011
+ ## app\services\auth.py
1012
+
1013
+ ```python
1014
+ from datetime import datetime, timedelta
1015
+ from typing import Optional
1016
+ from jose import JWTError, jwt
1017
+ from passlib.context import CryptContext
1018
+ from ..config import settings
1019
+ from ..models.user import User
1020
+ from ..database import get_database
1021
+
1022
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
1023
+
1024
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
1025
+ return pwd_context.verify(plain_password, hashed_password)
1026
+
1027
+ def get_password_hash(password: str) -> str:
1028
+ return pwd_context.hash(password)
1029
+
1030
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
1031
+ to_encode = data.copy()
1032
+ if expires_delta:
1033
+ expire = datetime.utcnow() + expires_delta
1034
+ else:
1035
+ expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
1036
+ to_encode.update({"exp": expire})
1037
+ encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
1038
+ return encoded_jwt
1039
+
1040
+ async def authenticate_user(username: str, password: str):
1041
+ db = get_database()
1042
+ user_dict = await db.users.find_one({"username": username})
1043
+ if not user_dict:
1044
+ return False
1045
+ user = User.model_validate(user_dict)
1046
+ if not verify_password(password, user.hashed_password):
1047
+ return False
1048
+ return user
1049
+ ```
1050
+
1051
+ ---
1052
+
1053
+ ## app\services\document.py
1054
+
1055
+ ```python
1056
+ from typing import List, Optional
1057
+ from bson import ObjectId
1058
+ from datetime import datetime
1059
+ import os
1060
+ import aiofiles
1061
+ from ..database import get_database
1062
+ from ..models.document import Document
1063
+ from ..schemas.document import DocumentCreate, DocumentUpdate
1064
+ from ..config import settings
1065
+ from .notification import NotificationService
1066
+
1067
+ class DocumentService:
1068
+ def __init__(self):
1069
+ self.db = get_database()
1070
+ self.notification_service = NotificationService()
1071
+
1072
+ async def create_document(self, document_data: DocumentCreate, file_info: dict, uploaded_by: str) -> Document:
1073
+ document_dict = document_data.model_dump()
1074
+ document_dict.update({
1075
+ "file_path": file_info["file_path"],
1076
+ "file_name": file_info["file_name"],
1077
+ "file_size": file_info["file_size"],
1078
+ "mime_type": file_info["mime_type"],
1079
+ "uploaded_by": uploaded_by,
1080
+ "created_at": datetime.utcnow(),
1081
+ "updated_at": datetime.utcnow()
1082
+ })
1083
+
1084
+ result = await self.db.documents.insert_one(document_dict)
1085
+ document_dict["_id"] = result.inserted_id
1086
+
1087
+ # Send notifications to assigned customers
1088
+ if document_data.assigned_customers:
1089
+ for customer_id in document_data.assigned_customers:
1090
+ await self.notification_service.create_notification(
1091
+ user_id=customer_id,
1092
+ type="new_document",
1093
+ title="New Document Available",
1094
+ message=f"A new document '{document_data.title}' has been uploaded for you.",
1095
+ document_id=str(result.inserted_id)
1096
+ )
1097
+
1098
+ return Document.model_validate(document_dict)
1099
+
1100
+ async def get_document_by_id(self, document_id: str) -> Optional[Document]:
1101
+ document_dict = await self.db.documents.find_one({"_id": ObjectId(document_id)})
1102
+ return Document.model_validate(document_dict) if document_dict else None
1103
+
1104
+ async def get_documents(self, skip: int = 0, limit: int = 100,
1105
+ category: Optional[str] = None,
1106
+ user_id: Optional[str] = None,
1107
+ user_role: Optional[str] = None) -> List[Document]:
1108
+ query = {}
1109
+
1110
+ if category:
1111
+ query["category"] = category
1112
+
1113
+ # Filter based on user role
1114
+ if user_role == "customer":
1115
+ query["$or"] = [
1116
+ {"visibility": "public"},
1117
+ {"assigned_customers": user_id}
1118
+ ]
1119
+
1120
+ cursor = self.db.documents.find(query).skip(skip).limit(limit)
1121
+ documents = []
1122
+ async for document_dict in cursor:
1123
+ documents.append(Document.model_validate(document_dict))
1124
+ return documents
1125
+
1126
+ async def update_document(self, document_id: str, document_update: DocumentUpdate) -> Optional[Document]:
1127
+ update_data = {k: v for k, v in document_update.model_dump().items() if v is not None}
1128
+ if update_data:
1129
+ update_data["updated_at"] = datetime.utcnow()
1130
+
1131
+ # Get old document for comparison
1132
+ old_doc = await self.get_document_by_id(document_id)
1133
+
1134
+ result = await self.db.documents.update_one(
1135
+ {"_id": ObjectId(document_id)},
1136
+ {"$set": update_data}
1137
+ )
1138
+
1139
+ if result.modified_count:
1140
+ # Send notifications if assigned customers changed
1141
+ if "assigned_customers" in update_data:
1142
+ new_customers = set(update_data["assigned_customers"]) - set(old_doc.assigned_customers)
1143
+ for customer_id in new_customers:
1144
+ await self.notification_service.create_notification(
1145
+ user_id=customer_id,
1146
+ type="new_document",
1147
+ title="Document Assigned",
1148
+ message=f"Document '{old_doc.title}' has been assigned to you.",
1149
+ document_id=document_id
1150
+ )
1151
+
1152
+ return await self.get_document_by_id(document_id)
1153
+ return None
1154
+
1155
+ async def delete_document(self, document_id: str) -> bool:
1156
+ # Get document to delete file
1157
+ document = await self.get_document_by_id(document_id)
1158
+ if document:
1159
+ # Delete file from filesystem
1160
+ try:
1161
+ os.remove(document.file_path)
1162
+ except:
1163
+ pass
1164
+
1165
+ # Delete from database
1166
+ result = await self.db.documents.delete_one({"_id": ObjectId(document_id)})
1167
+ return result.deleted_count > 0
1168
+ return False
1169
+
1170
+ async def save_file(self, file_content: bytes, filename: str, category: str) -> dict:
1171
+ # Create directory if not exists
1172
+ upload_dir = os.path.join(settings.UPLOAD_DIR, category)
1173
+ os.makedirs(upload_dir, exist_ok=True)
1174
+
1175
+ # Generate unique filename
1176
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
1177
+ safe_filename = f"{timestamp}_{filename}"
1178
+ file_path = os.path.join(upload_dir, safe_filename)
1179
+
1180
+ # Save file
1181
+ async with aiofiles.open(file_path, 'wb') as f:
1182
+ await f.write(file_content)
1183
+
1184
+ return {
1185
+ "file_path": file_path,
1186
+ "file_name": filename,
1187
+ "file_size": len(file_content),
1188
+ "mime_type": "application/octet-stream"
1189
+ }
1190
+ ```
1191
+
1192
+ ---
1193
+
1194
+ ## app\services\email.py
1195
+
1196
+ ```python
1197
+ import smtplib
1198
+ from email.mime.text import MIMEText
1199
+ from email.mime.multipart import MIMEMultipart
1200
+ from typing import List
1201
+ from ..config import settings
1202
+
1203
+ async def send_email(to_email: str, subject: str, body: str, is_html: bool = False):
1204
+ """Send email using SMTP"""
1205
+ if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
1206
+ print(f"Email sending skipped (not configured): {subject} to {to_email}")
1207
+ return
1208
+
1209
+ msg = MIMEMultipart()
1210
+ msg['From'] = settings.EMAILS_FROM_EMAIL or settings.SMTP_USER
1211
+ msg['To'] = to_email
1212
+ msg['Subject'] = subject
1213
+
1214
+ msg.attach(MIMEText(body, 'html' if is_html else 'plain'))
1215
+
1216
+ try:
1217
+ server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
1218
+ server.starttls()
1219
+ server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
1220
+ server.send_message(msg)
1221
+ server.quit()
1222
+ except Exception as e:
1223
+ print(f"Failed to send email: {e}")
1224
+
1225
+ async def send_welcome_email(email: str, username: str):
1226
+ subject = "Welcome to Metsa Document Portal"
1227
+ body = f"""
1228
+ <h2>Welcome to Metsa Document Portal</h2>
1229
+ <p>Hello {username},</p>
1230
+ <p>Your account has been created successfully. You can now log in to access your documents.</p>
1231
+ <p>Username: {username}</p>
1232
+ <p>Please contact your administrator if you need to reset your password.</p>
1233
+ <br>
1234
+ <p>Best regards,<br>Metsa Team</p>
1235
+ """
1236
+ await send_email(email, subject, body, is_html=True)
1237
+
1238
+ async def send_password_reset_email(email: str, reset_token: str):
1239
+ subject = "Password Reset Request"
1240
+ body = f"""
1241
+ <h2>Password Reset Request</h2>
1242
+ <p>You have requested to reset your password. Click the link below to reset it:</p>
1243
+ <p><a href="http://portal.metsa.com/reset-password?token={reset_token}">Reset Password</a></p>
1244
+ <p>This link will expire in 1 hour.</p>
1245
+ <p>If you didn't request this, please ignore this email.</p>
1246
+ <br>
1247
+ <p>Best regards,<br>Metsa Team</p>
1248
+ """
1249
+ await send_email(email, subject, body, is_html=True)
1250
+
1251
+ ```
1252
+
1253
+ ---
1254
+
1255
+ ## app\services\notification.py
1256
+
1257
+ ```python
1258
+ from typing import List, Optional
1259
+ from bson import ObjectId
1260
+ from datetime import datetime
1261
+ from ..database import get_database
1262
+ from ..models.notification import Notification, NotificationType
1263
+
1264
+ class NotificationService:
1265
+ def __init__(self):
1266
+ self.db = get_database()
1267
+
1268
+ async def create_notification(self, user_id: str, type: str, title: str,
1269
+ message: str, document_id: Optional[str] = None) -> Notification:
1270
+ notification_dict = {
1271
+ "user_id": user_id,
1272
+ "type": type,
1273
+ "title": title,
1274
+ "message": message,
1275
+ "document_id": document_id,
1276
+ "is_read": False,
1277
+ "created_at": datetime.utcnow()
1278
+ }
1279
+
1280
+ result = await self.db.notifications.insert_one(notification_dict)
1281
+ notification_dict["_id"] = result.inserted_id
1282
+
1283
+ return Notification.model_validate(notification_dict)
1284
+
1285
+ async def get_user_notifications(self, user_id: str, skip: int = 0,
1286
+ limit: int = 50, unread_only: bool = False) -> List[Notification]:
1287
+ query = {"user_id": user_id}
1288
+ if unread_only:
1289
+ query["is_read"] = False
1290
+
1291
+ cursor = self.db.notifications.find(query).sort("created_at", -1).skip(skip).limit(limit)
1292
+ notifications = []
1293
+ async for notification_dict in cursor:
1294
+ notifications.append(Notification.model_validate(notification_dict))
1295
+ return notifications
1296
+
1297
+ async def mark_as_read(self, notification_id: str, user_id: str) -> bool:
1298
+ result = await self.db.notifications.update_one(
1299
+ {"_id": ObjectId(notification_id), "user_id": user_id},
1300
+ {"$set": {"is_read": True}}
1301
+ )
1302
+ return result.modified_count > 0
1303
+
1304
+ async def mark_all_as_read(self, user_id: str) -> int:
1305
+ result = await self.db.notifications.update_many(
1306
+ {"user_id": user_id, "is_read": False},
1307
+ {"$set": {"is_read": True}}
1308
+ )
1309
+ return result.modified_count
1310
+ ```
1311
+
1312
+ ---
1313
+
1314
+ ## app\services\user.py
1315
+
1316
+ ```python
1317
+ from typing import List, Optional
1318
+ from bson import ObjectId
1319
+ from datetime import datetime
1320
+ from ..database import get_database
1321
+ from ..models.user import User, UserRole, Permission
1322
+ from ..schemas.user import UserCreate, UserUpdate
1323
+ from .auth import get_password_hash
1324
+ from .email import send_welcome_email
1325
+
1326
+ class UserService:
1327
+ def __init__(self):
1328
+ self.db = get_database()
1329
+
1330
+ async def create_user(self, user_data: UserCreate, created_by: str) -> User:
1331
+ # Check if user already exists
1332
+ existing_user = await self.db.users.find_one({
1333
+ "$or": [
1334
+ {"email": user_data.email},
1335
+ {"username": user_data.username}
1336
+ ]
1337
+ })
1338
+ if existing_user:
1339
+ raise ValueError("User with this email or username already exists")
1340
+
1341
+ # Create user
1342
+ user_dict = user_data.model_dump()
1343
+ user_dict["hashed_password"] = get_password_hash(user_dict.pop("password"))
1344
+ user_dict["created_by"] = created_by
1345
+ user_dict["created_at"] = datetime.utcnow()
1346
+ user_dict["updated_at"] = datetime.utcnow()
1347
+
1348
+ # Set default permissions based on role
1349
+ if user_data.role == UserRole.ADMIN:
1350
+ user_dict["permissions"] = [p.value for p in Permission]
1351
+ elif user_data.role == UserRole.EDITOR:
1352
+ user_dict["permissions"] = user_data.permissions or [Permission.VIEW, Permission.UPLOAD, Permission.EDIT]
1353
+ else:
1354
+ user_dict["permissions"] = [Permission.VIEW]
1355
+
1356
+ result = await self.db.users.insert_one(user_dict)
1357
+ user_dict["_id"] = result.inserted_id
1358
+
1359
+ # Send welcome email
1360
+ await send_welcome_email(user_data.email, user_data.username)
1361
+
1362
+ return User.model_validate(user_dict)
1363
+
1364
+ async def get_user_by_id(self, user_id: str) -> Optional[User]:
1365
+ user_dict = await self.db.users.find_one({"_id": ObjectId(user_id)})
1366
+ return User.model_validate(user_dict) if user_dict else None
1367
+
1368
+ async def get_user_by_username(self, username: str) -> Optional[User]:
1369
+ user_dict = await self.db.users.find_one({"username": username})
1370
+ return User.model_validate(user_dict) if user_dict else None
1371
+
1372
+ async def get_users(self, skip: int = 0, limit: int = 100, role: Optional[UserRole] = None) -> List[User]:
1373
+ query = {}
1374
+ if role:
1375
+ query["role"] = role
1376
+
1377
+ cursor = self.db.users.find(query).skip(skip).limit(limit)
1378
+ users = []
1379
+ async for user_dict in cursor:
1380
+ users.append(User.model_validate(user_dict))
1381
+ return users
1382
+
1383
+ async def update_user(self, user_id: str, user_update: UserUpdate) -> Optional[User]:
1384
+ update_data = {k: v for k, v in user_update.model_dump().items() if v is not None}
1385
+ if update_data:
1386
+ update_data["updated_at"] = datetime.utcnow()
1387
+ result = await self.db.users.update_one(
1388
+ {"_id": ObjectId(user_id)},
1389
+ {"$set": update_data}
1390
+ )
1391
+ if result.modified_count:
1392
+ return await self.get_user_by_id(user_id)
1393
+ return None
1394
+
1395
+ async def delete_user(self, user_id: str) -> bool:
1396
+ result = await self.db.users.delete_one({"_id": ObjectId(user_id)})
1397
+ return result.deleted_count > 0
1398
+
1399
+ async def update_last_login(self, user_id: str):
1400
+ await self.db.users.update_one(
1401
+ {"_id": ObjectId(user_id)},
1402
+ {"$set": {"last_login": datetime.utcnow()}}
1403
+ )
1404
+ ```
1405
+
1406
+ ---
1407
+
1408
+ ## app\utils\__init__.py
1409
+
1410
+ ```python
1411
+
1412
+ ```
1413
+
1414
+ ---
1415
+
1416
+ ## app\utils\permissions.py
1417
+
1418
+ ```python
1419
+ from typing import List
1420
+ from fastapi import HTTPException, status
1421
+ from ..models.user import User, UserRole, Permission
1422
+
1423
+ def check_permission(user: User, required_permissions: List[Permission]):
1424
+ """Check if user has required permissions"""
1425
+ if user.role == UserRole.ADMIN:
1426
+ return True
1427
+
1428
+ user_permissions = set(user.permissions)
1429
+ required_permissions_set = set(required_permissions)
1430
+
1431
+ if not required_permissions_set.issubset(user_permissions):
1432
+ raise HTTPException(
1433
+ status_code=status.HTTP_403_FORBIDDEN,
1434
+ detail="Not enough permissions"
1435
+ )
1436
+
1437
+ return True
1438
+
1439
+ def is_admin(user: User):
1440
+ """Check if user is admin"""
1441
+ if user.role != UserRole.ADMIN:
1442
+ raise HTTPException(
1443
+ status_code=status.HTTP_403_FORBIDDEN,
1444
+ detail="Admin access required"
1445
+ )
1446
+ return True
1447
+
1448
+ def is_staff(user: User):
1449
+ """Check if user is admin or editor"""
1450
+ if user.role not in [UserRole.ADMIN, UserRole.EDITOR]:
1451
+ raise HTTPException(
1452
+ status_code=status.HTTP_403_FORBIDDEN,
1453
+ detail="Staff access required"
1454
+ )
1455
+ return True
1456
+
1457
+ def can_access_document(user: User, document):
1458
+ """Check if user can access a document"""
1459
+ if user.role in [UserRole.ADMIN, UserRole.EDITOR]:
1460
+ return True
1461
+
1462
+ if document.visibility == "public":
1463
+ return True
1464
+
1465
+ if str(user.id) in document.assigned_customers:
1466
+ return True
1467
+
1468
+ raise HTTPException(
1469
+ status_code=status.HTTP_403_FORBIDDEN,
1470
+ detail="You don't have access to this document"
1471
+ )
1472
+
1473
+ ```
1474
+
1475
+ ---
1476
+
1477
+ ## app\utils\security.py
1478
+
1479
+ ```python
1480
+ from typing import Optional
1481
+ from datetime import datetime, timedelta
1482
+ from fastapi import Depends, HTTPException, status
1483
+ from fastapi.security import OAuth2PasswordBearer
1484
+ from jose import JWTError, jwt
1485
+ from ..config import settings
1486
+ from ..schemas.user import TokenData
1487
+ from ..services.user import UserService
1488
+
1489
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
1490
+
1491
+ async def get_current_user(token: str = Depends(oauth2_scheme)):
1492
+ credentials_exception = HTTPException(
1493
+ status_code=status.HTTP_401_UNAUTHORIZED,
1494
+ detail="Could not validate credentials",
1495
+ headers={"WWW-Authenticate": "Bearer"},
1496
+ )
1497
+
1498
+ try:
1499
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
1500
+ username: str = payload.get("sub")
1501
+ user_id: str = payload.get("user_id")
1502
+ role: str = payload.get("role")
1503
+
1504
+ if username is None or user_id is None:
1505
+ raise credentials_exception
1506
+
1507
+ token_data = TokenData(username=username, user_id=user_id, role=role)
1508
+ except JWTError:
1509
+ raise credentials_exception
1510
+
1511
+ user_service = UserService()
1512
+ user = await user_service.get_user_by_username(username=token_data.username)
1513
+
1514
+ if user is None:
1515
+ raise credentials_exception
1516
+
1517
+ if not user.is_active:
1518
+ raise HTTPException(
1519
+ status_code=status.HTTP_400_BAD_REQUEST,
1520
+ detail="Inactive user"
1521
+ )
1522
+
1523
+ return user
1524
+
1525
+ async def get_current_active_user(current_user = Depends(get_current_user)):
1526
+ if not current_user.is_active:
1527
+ raise HTTPException(status_code=400, detail="Inactive user")
1528
+ return current_user
1529
+ ```
1530
+
1531
+ ---
1532
+
1533
+ ## documentation.py
1534
+
1535
+ ```python
1536
+ import os
1537
+ import fnmatch
1538
+ from pathlib import Path
1539
+
1540
+ def should_skip_file(filepath, filename):
1541
+ """Check if file should be skipped based on patterns"""
1542
+ skip_patterns = [
1543
+ '__pycache__',
1544
+ '*.pyc',
1545
+ '.env*',
1546
+ '*.log',
1547
+ '.git*',
1548
+ '*.tmp',
1549
+ '*.temp',
1550
+ 'node_modules',
1551
+ '.vscode',
1552
+ '.idea',
1553
+ '*.DS_Store',
1554
+ 'Thumbs.db'
1555
+ ]
1556
+
1557
+ skip_dirs = [
1558
+ '__pycache__',
1559
+ '.git',
1560
+ 'node_modules',
1561
+ '.vscode',
1562
+ '.idea',
1563
+ 'venv',
1564
+ 'env',
1565
+ '.env'
1566
+ ]
1567
+
1568
+ # Check if any parent directory should be skipped
1569
+ path_parts = Path(filepath).parts
1570
+ for part in path_parts:
1571
+ if part in skip_dirs:
1572
+ return True
1573
+
1574
+ # Check if filename matches skip patterns
1575
+ for pattern in skip_patterns:
1576
+ if fnmatch.fnmatch(filename, pattern):
1577
+ return True
1578
+
1579
+ return False
1580
+
1581
+ def get_file_content(filepath):
1582
+ """Read file content safely"""
1583
+ try:
1584
+ with open(filepath, 'r', encoding='utf-8') as file:
1585
+ return file.read()
1586
+ except UnicodeDecodeError:
1587
+ try:
1588
+ with open(filepath, 'r', encoding='latin-1') as file:
1589
+ return file.read()
1590
+ except Exception as e:
1591
+ return f"Error reading file: {str(e)}"
1592
+ except Exception as e:
1593
+ return f"Error reading file: {str(e)}"
1594
+
1595
+ def generate_documentation(root_path='.', output_file='PROJECT_DOCUMENTATION.md'):
1596
+ """Generate complete project documentation"""
1597
+
1598
+ documentation = []
1599
+ documentation.append("# Project Documentation\n")
1600
+ documentation.append(f"Generated from: {os.path.abspath(root_path)}\n")
1601
+ documentation.append("---\n")
1602
+
1603
+ # Get all Python files
1604
+ python_files = []
1605
+
1606
+ for root, dirs, files in os.walk(root_path):
1607
+ # Remove directories that should be skipped
1608
+ dirs[:] = [d for d in dirs if not should_skip_file(os.path.join(root, d), d)]
1609
+
1610
+ for file in files:
1611
+ filepath = os.path.join(root, file)
1612
+
1613
+ # Skip system files and focus on Python files
1614
+ if should_skip_file(filepath, file):
1615
+ continue
1616
+
1617
+ # Only include Python files and important config files
1618
+ if file.endswith('.py') or file in ['requirements.txt', 'README.md', '.env.example']:
1619
+ python_files.append(filepath)
1620
+
1621
+ # Sort files for consistent output
1622
+ python_files.sort()
1623
+
1624
+ # Generate documentation for each file
1625
+ for filepath in python_files:
1626
+ relative_path = os.path.relpath(filepath, root_path)
1627
+
1628
+ # Add file header
1629
+ documentation.append(f"## {relative_path}\n")
1630
+
1631
+ # Get file content
1632
+ content = get_file_content(filepath)
1633
+
1634
+ # Add code block with syntax highlighting
1635
+ if filepath.endswith('.py'):
1636
+ documentation.append("```python")
1637
+ elif filepath.endswith('.md'):
1638
+ documentation.append("```markdown")
1639
+ elif filepath.endswith('.txt'):
1640
+ documentation.append("```text")
1641
+ else:
1642
+ documentation.append("```")
1643
+
1644
+ documentation.append(content)
1645
+ documentation.append("```\n")
1646
+ documentation.append("---\n")
1647
+
1648
+ # Write documentation to file
1649
+ with open(output_file, 'w', encoding='utf-8') as f:
1650
+ f.write('\n'.join(documentation))
1651
+
1652
+ print(f"Documentation generated successfully: {output_file}")
1653
+ print(f"Total files documented: {len(python_files)}")
1654
+
1655
+ def main():
1656
+ """Main function to run the documentation generator"""
1657
+ # You can modify these paths as needed
1658
+ project_root = "." # Current directory
1659
+ output_filename = "PROJECT_DOCUMENTATION.md"
1660
+
1661
+ print("Starting documentation generation...")
1662
+ print(f"Project root: {os.path.abspath(project_root)}")
1663
+ print(f"Output file: {output_filename}")
1664
+ print("=" * 50)
1665
+
1666
+ generate_documentation(project_root, output_filename)
1667
+
1668
+ print("=" * 50)
1669
+ print("Documentation generation completed!")
1670
+
1671
+ if __name__ == "__main__":
1672
+ main()
1673
+ ```
1674
+
1675
+ ---
1676
+
1677
+ ## requirements.txt
1678
+
1679
+ ```text
1680
+ fastapi==0.104.1
1681
+ uvicorn==0.24.0
1682
+ pymongo==4.5.0
1683
+ motor==3.3.2
1684
+ python-jose[cryptography]==3.3.0
1685
+ passlib[bcrypt]==1.7.4
1686
+ python-multipart==0.0.6
1687
+ python-dotenv==1.0.0
1688
+ pydantic==2.4.2
1689
+ pydantic[email]==2.4.2
1690
+ pydantic-settings==2.0.3
1691
+ aiofiles==23.2.1
1692
+ python-dateutil==2.8.2
1693
+ ```
1694
+
1695
+ ---
metsa-backend/README.md ADDED
File without changes
metsa-backend/app/__init__.py ADDED
File without changes
metsa-backend/app/config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+
4
+ class Settings(BaseModel):
5
+ # Application
6
+ APP_NAME: str = "Document Portal API"
7
+ APP_VERSION: str = "1.0.0"
8
+ DEBUG: bool = True
9
+ BASE_URL: str = "http://localhost:8000"
10
+
11
+ # MongoDB
12
+ MONGODB_URL: str = "mongodb://localhost:27017"
13
+ DATABASE_NAME: str = "document_portal"
14
+
15
+ # Security
16
+ SECRET_KEY: str = "your-secret-key-here-change-in-production"
17
+ ALGORITHM: str = "HS256"
18
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
19
+
20
+ # Email
21
+ SMTP_HOST: str = "smtp.gmail.com"
22
+ SMTP_PORT: int = 587
23
+ SMTP_USER: str = ""
24
+ SMTP_PASSWORD: str = ""
25
+ EMAILS_FROM_EMAIL: Optional[str] = None
26
+ EMAILS_FROM_NAME: Optional[str] = None
27
+
28
+ # File Upload
29
+ UPLOAD_DIR: str = "uploads"
30
+ MAX_FILE_SIZE: int = 10 * 1024 * 1024
31
+ ALLOWED_EXTENSIONS: set = {".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"}
32
+
33
+ model_config = {"env_file": ".env"}
34
+
35
+ # Create settings instance
36
+ settings = Settings()
metsa-backend/app/database.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from motor.motor_asyncio import AsyncIOMotorClient
2
+ from typing import Optional
3
+ from .config import settings
4
+
5
+ class Database:
6
+ client: Optional[AsyncIOMotorClient] = None
7
+
8
+ db = Database()
9
+
10
+ async def connect_to_mongo():
11
+ db.client = AsyncIOMotorClient(settings.MONGODB_URL)
12
+
13
+ async def close_mongo_connection():
14
+ if db.client:
15
+ db.client.close()
16
+
17
+ def get_database():
18
+ return db.client[settings.DATABASE_NAME]
metsa-backend/app/main.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import FileResponse
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+ import os
7
+
8
+ from .config import settings
9
+ from .database import connect_to_mongo, close_mongo_connection
10
+ from .routers import auth, users, documents, notifications
11
+ from .services.user import UserService
12
+ from .schemas.user import UserCreate, UserUpdate
13
+ from .models.user import UserRole
14
+
15
+ @asynccontextmanager
16
+ async def lifespan(app: FastAPI):
17
+ # Startup
18
+ await connect_to_mongo()
19
+
20
+ # Create upload directory
21
+ os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
22
+
23
+ # Create default admin user if not exists
24
+ user_service = UserService()
25
+ admin = await user_service.get_user_by_username("admin")
26
+ if not admin:
27
+ admin_data = UserCreate(
28
+ email="admin@metsa.com",
29
+ username="admin",
30
+ password="admin123", # Change this in production!
31
+ role=UserRole.ADMIN
32
+ )
33
+ await user_service.create_user(admin_data, "system")
34
+ print("Default admin user created: username=admin, password=admin123")
35
+ else:
36
+ # Ensure existing admin account has admin role and is active
37
+ if getattr(admin, 'role', None) != UserRole.ADMIN or not getattr(admin, 'is_active', True):
38
+ await user_service.update_user(
39
+ str(admin.id),
40
+ UserUpdate(role=UserRole.ADMIN, **({"is_active": True}))
41
+ )
42
+ print("Ensured default admin user has ADMIN role and is active")
43
+
44
+ yield
45
+
46
+ # Shutdown
47
+ await close_mongo_connection()
48
+
49
+ app = FastAPI(
50
+ title=settings.APP_NAME,
51
+ version=settings.APP_VERSION,
52
+ lifespan=lifespan
53
+ )
54
+
55
+ # CORS middleware
56
+ app.add_middleware(
57
+ CORSMiddleware,
58
+ allow_origins=["*"], # Configure this properly in production
59
+ allow_credentials=True,
60
+ allow_methods=["*"],
61
+ allow_headers=["*"],
62
+ )
63
+
64
+ # Include routers
65
+ app.include_router(auth.router, prefix="/api/v1")
66
+ app.include_router(users.router, prefix="/api/v1")
67
+ app.include_router(documents.router, prefix="/api/v1")
68
+ app.include_router(notifications.router, prefix="/api/v1")
69
+
70
+ @app.get("/")
71
+ async def root():
72
+ return {
73
+ "message": "Welcome to Metsa Document Portal API",
74
+ "version": settings.APP_VERSION,
75
+ "docs": "/docs"
76
+ }
77
+
78
+ # Serve Next.js static export (built assets)
79
+ FRONTEND_DIR = Path(__file__).resolve().parents[2] / "metsa-frontend" / "out"
80
+ INDEX_FILE = FRONTEND_DIR / "index.html"
81
+
82
+ @app.get("/{full_path:path}")
83
+ async def serve_frontend(full_path: str):
84
+ # API routes are already handled by routers with /api/v1 prefix
85
+ # This catches everything else and serves static files from Next.js export
86
+ target = (FRONTEND_DIR / full_path).resolve()
87
+ try:
88
+ # Prevent path traversal
89
+ target.relative_to(FRONTEND_DIR)
90
+ except Exception:
91
+ raise HTTPException(status_code=404, detail="Not Found")
92
+
93
+ if target.is_dir():
94
+ index_in_dir = target / "index.html"
95
+ if index_in_dir.exists():
96
+ return FileResponse(index_in_dir)
97
+ if target.exists() and target.is_file():
98
+ return FileResponse(target)
99
+ # Fallback to top-level index.html for SPA routes
100
+ if INDEX_FILE.exists():
101
+ return FileResponse(INDEX_FILE)
102
+ raise HTTPException(status_code=404, detail="Frontend not built")
103
+
104
+
105
+ @app.get("/health")
106
+ async def health_check():
107
+ return {"status": "healthy"}
metsa-backend/app/middleware/__init__.py ADDED
File without changes
metsa-backend/app/middleware/auth.py ADDED
File without changes
metsa-backend/app/models/__init__.py ADDED
File without changes
metsa-backend/app/models/document.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, List, Dict
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from pydantic import BaseModel, Field
5
+ from bson import ObjectId
6
+ from .user import PyObjectId
7
+
8
+ class DocumentCategory(str, Enum):
9
+ COMMERCIAL = "commercial"
10
+ QUALITY = "quality"
11
+ SAFETY = "safety"
12
+ COMPLIANCE = "compliance"
13
+ CONTRACTS = "contracts"
14
+ SPECIFICATIONS = "specifications"
15
+ OTHER = "other"
16
+
17
+ class DocumentVisibility(str, Enum):
18
+ PUBLIC = "public"
19
+ PRIVATE = "private"
20
+ INTERNAL = "internal"
21
+
22
+ class Document(BaseModel):
23
+ model_config = {"arbitrary_types_allowed": True, "populate_by_name": True}
24
+
25
+ id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
26
+ title: str
27
+ description: Optional[str] = None
28
+ category: DocumentCategory
29
+ file_path: str
30
+ file_name: str
31
+ file_size: int
32
+ mime_type: str
33
+ visibility: DocumentVisibility = DocumentVisibility.PRIVATE
34
+ is_viewable_only: bool = False
35
+ assigned_customers: List[str] = []
36
+ tags: List[str] = []
37
+ metadata: Optional[Dict] = None
38
+ uploaded_by: str
39
+ created_at: datetime = Field(default_factory=datetime.utcnow)
40
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
metsa-backend/app/models/notification.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from pydantic import BaseModel, Field
5
+ from bson import ObjectId
6
+ from .user import PyObjectId
7
+
8
+ class NotificationType(str, Enum):
9
+ NEW_DOCUMENT = "new_document"
10
+ DOCUMENT_UPDATED = "document_updated"
11
+ SYSTEM = "system"
12
+
13
+ class Notification(BaseModel):
14
+ model_config = {"arbitrary_types_allowed": True, "populate_by_name": True}
15
+
16
+ id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
17
+ user_id: str
18
+ type: NotificationType
19
+ title: str
20
+ message: str
21
+ document_id: Optional[str] = None
22
+ is_read: bool = False
23
+ created_at: datetime = Field(default_factory=datetime.utcnow)
metsa-backend/app/models/user.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, List, Dict, Any
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from pydantic import BaseModel, EmailStr, Field, field_validator
5
+ from pydantic.json_schema import JsonSchemaValue
6
+ from pydantic_core import core_schema
7
+ from bson import ObjectId
8
+
9
+ class UserRole(str, Enum):
10
+ ADMIN = "admin"
11
+ EDITOR = "editor"
12
+ CUSTOMER = "customer"
13
+
14
+ class Permission(str, Enum):
15
+ UPLOAD = "upload"
16
+ EDIT = "edit"
17
+ DELETE = "delete"
18
+ VIEW = "view"
19
+ MANAGE_USERS = "manage_users"
20
+ MANAGE_DOCUMENTS = "manage_documents"
21
+
22
+ class PyObjectId(ObjectId):
23
+ @classmethod
24
+ def __get_pydantic_core_schema__(
25
+ cls, source_type: Any, handler
26
+ ) -> core_schema.CoreSchema:
27
+ return core_schema.json_or_python_schema(
28
+ json_schema=core_schema.str_schema(),
29
+ python_schema=core_schema.union_schema([
30
+ core_schema.is_instance_schema(ObjectId),
31
+ core_schema.chain_schema([
32
+ core_schema.str_schema(),
33
+ core_schema.no_info_plain_validator_function(cls.validate),
34
+ ])
35
+ ]),
36
+ serialization=core_schema.plain_serializer_function_ser_schema(
37
+ lambda x: str(x)
38
+ ),
39
+ )
40
+
41
+ @classmethod
42
+ def validate(cls, v):
43
+ if not ObjectId.is_valid(v):
44
+ raise ValueError("Invalid ObjectId")
45
+ return ObjectId(v)
46
+
47
+ @classmethod
48
+ def __get_pydantic_json_schema__(
49
+ cls, core_schema: core_schema.CoreSchema, handler
50
+ ) -> JsonSchemaValue:
51
+ return {"type": "string"}
52
+
53
+ class User(BaseModel):
54
+ model_config = {"arbitrary_types_allowed": True, "populate_by_name": True}
55
+
56
+ id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
57
+ email: EmailStr
58
+ username: str
59
+ hashed_password: str
60
+ role: UserRole
61
+ is_active: bool = True
62
+ is_verified: bool = False
63
+ permissions: List[Permission] = []
64
+ customer_info: Optional[Dict] = None
65
+ created_at: datetime = Field(default_factory=datetime.utcnow)
66
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
67
+ created_by: Optional[str] = None
68
+ last_login: Optional[datetime] = None
metsa-backend/app/routers/__init__.py ADDED
File without changes
metsa-backend/app/routers/auth.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import timedelta
2
+ from fastapi import APIRouter, Depends, HTTPException, status
3
+ from fastapi.security import OAuth2PasswordRequestForm
4
+ from ..schemas.user import Token, UserLogin, PasswordChange
5
+ from ..services.auth import authenticate_user, create_access_token
6
+ from ..services.user import UserService
7
+ from ..models.user import User
8
+ from ..utils.security import get_current_active_user
9
+ from ..config import settings
10
+
11
+ router = APIRouter(prefix="/auth", tags=["authentication"])
12
+
13
+ @router.post("/login", response_model=Token)
14
+ async def login(form_data: OAuth2PasswordRequestForm = Depends()):
15
+ user = await authenticate_user(form_data.username, form_data.password)
16
+ if not user:
17
+ raise HTTPException(
18
+ status_code=status.HTTP_401_UNAUTHORIZED,
19
+ detail="Incorrect username or password",
20
+ headers={"WWW-Authenticate": "Bearer"},
21
+ )
22
+
23
+ if not user.is_active:
24
+ raise HTTPException(
25
+ status_code=status.HTTP_400_BAD_REQUEST,
26
+ detail="Inactive user"
27
+ )
28
+
29
+ # Update last login
30
+ user_service = UserService()
31
+ await user_service.update_last_login(str(user.id))
32
+
33
+ access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
34
+ access_token = create_access_token(
35
+ data={
36
+ "sub": user.username,
37
+ "user_id": str(user.id),
38
+ "role": user.role
39
+ },
40
+ expires_delta=access_token_expires
41
+ )
42
+
43
+ return {"access_token": access_token, "token_type": "bearer"}
44
+
45
+ @router.post("/change-password")
46
+ async def change_password(
47
+ password_data: PasswordChange,
48
+ current_user: User = Depends(get_current_active_user)
49
+ ):
50
+ """Change user password"""
51
+ user_service = UserService()
52
+
53
+ success = await user_service.change_password(
54
+ str(current_user.id),
55
+ password_data.current_password,
56
+ password_data.new_password
57
+ )
58
+
59
+ if not success:
60
+ raise HTTPException(
61
+ status_code=status.HTTP_400_BAD_REQUEST,
62
+ detail="Current password is incorrect"
63
+ )
64
+
65
+ return {"message": "Password changed successfully"}
metsa-backend/app/routers/documents.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
3
+ from fastapi.responses import FileResponse
4
+ from ..schemas.document import DocumentCreate, DocumentUpdate, DocumentResponse
5
+ from ..models.user import User, Permission, UserRole
6
+ from ..models.document import DocumentCategory, DocumentVisibility
7
+ from ..services.document import DocumentService
8
+ from ..utils.security import get_current_active_user
9
+ from ..utils.permissions import is_staff, check_permission, can_access_document
10
+ from ..config import settings
11
+ import os
12
+ import mimetypes
13
+
14
+ router = APIRouter(prefix="/documents", tags=["documents"])
15
+
16
+ @router.post("/", response_model=DocumentResponse)
17
+ async def upload_document(
18
+ title: str = Form(...),
19
+ description: Optional[str] = Form(None),
20
+ category: DocumentCategory = Form(...),
21
+ visibility: DocumentVisibility = Form(DocumentVisibility.PRIVATE),
22
+ is_viewable_only: bool = Form(False),
23
+ assigned_customers: Optional[str] = Form(None),
24
+ tags: Optional[str] = Form(None),
25
+ file: UploadFile = File(...),
26
+ current_user: User = Depends(get_current_active_user)
27
+ ):
28
+ """Upload a new document (Staff only)"""
29
+ check_permission(current_user, [Permission.UPLOAD])
30
+
31
+ document_service = DocumentService()
32
+
33
+ # Validate file
34
+ if not file.filename:
35
+ raise HTTPException(status_code=400, detail="No file provided")
36
+
37
+ file_extension = os.path.splitext(file.filename)[1].lower()
38
+ if file_extension not in settings.ALLOWED_EXTENSIONS:
39
+ raise HTTPException(
40
+ status_code=400,
41
+ detail=f"File type not allowed. Allowed types: {', '.join(settings.ALLOWED_EXTENSIONS)}"
42
+ )
43
+
44
+ # Read file content
45
+ content = await file.read()
46
+ if len(content) > settings.MAX_FILE_SIZE:
47
+ raise HTTPException(
48
+ status_code=400,
49
+ detail=f"File too large. Maximum size: {settings.MAX_FILE_SIZE / 1024 / 1024}MB"
50
+ )
51
+
52
+ # Save file
53
+ file_info = await document_service.save_file(content, file.filename, category)
54
+
55
+ # Parse form data
56
+ document_data = DocumentCreate(
57
+ title=title,
58
+ description=description,
59
+ category=category,
60
+ visibility=visibility,
61
+ is_viewable_only=is_viewable_only,
62
+ assigned_customers=assigned_customers.split(",") if assigned_customers else [],
63
+ tags=tags.split(",") if tags else []
64
+ )
65
+
66
+ # Create document
67
+ document = await document_service.create_document(
68
+ document_data,
69
+ file_info,
70
+ str(current_user.id)
71
+ )
72
+
73
+ return DocumentResponse(
74
+ id=str(document.id),
75
+ title=document.title,
76
+ description=document.description,
77
+ category=document.category,
78
+ visibility=document.visibility,
79
+ is_viewable_only=document.is_viewable_only,
80
+ assigned_customers=document.assigned_customers,
81
+ tags=document.tags,
82
+ file_name=document.file_name,
83
+ file_size=document.file_size,
84
+ mime_type=document.mime_type,
85
+ uploaded_by=document.uploaded_by,
86
+ created_at=document.created_at,
87
+ updated_at=document.updated_at
88
+ )
89
+
90
+ @router.post("/customer-upload", response_model=DocumentResponse)
91
+ async def customer_upload_document(
92
+ title: str = Form(...),
93
+ description: Optional[str] = Form(None),
94
+ category: DocumentCategory = Form(...),
95
+ tags: Optional[str] = Form(None),
96
+ file: UploadFile = File(...),
97
+ current_user: User = Depends(get_current_active_user)
98
+ ):
99
+ """Upload a new document (Customer submission for review)"""
100
+ # Only allow customers to use this endpoint
101
+ if current_user.role != UserRole.CUSTOMER:
102
+ raise HTTPException(
103
+ status_code=403,
104
+ detail="This endpoint is for customer submissions only"
105
+ )
106
+
107
+ document_service = DocumentService()
108
+
109
+ # Validate file
110
+ if not file.filename:
111
+ raise HTTPException(status_code=400, detail="No file provided")
112
+
113
+ file_extension = os.path.splitext(file.filename)[1].lower()
114
+ if file_extension not in settings.ALLOWED_EXTENSIONS:
115
+ raise HTTPException(
116
+ status_code=400,
117
+ detail=f"File type not allowed. Allowed types: {', '.join(settings.ALLOWED_EXTENSIONS)}"
118
+ )
119
+
120
+ # Read file content
121
+ content = await file.read()
122
+ if len(content) > settings.MAX_FILE_SIZE:
123
+ raise HTTPException(
124
+ status_code=400,
125
+ detail=f"File too large. Maximum size: {settings.MAX_FILE_SIZE / 1024 / 1024}MB"
126
+ )
127
+
128
+ # Save file
129
+ file_info = await document_service.save_file(content, file.filename, category)
130
+
131
+ # Parse form data - customer uploads are always private and require review
132
+ document_data = DocumentCreate(
133
+ title=title,
134
+ description=description,
135
+ category=category,
136
+ visibility=DocumentVisibility.PRIVATE, # Always private for customer uploads
137
+ is_viewable_only=False,
138
+ assigned_customers=[], # No assignments for customer uploads
139
+ tags=tags.split(",") if tags else []
140
+ )
141
+
142
+ # Create document
143
+ document = await document_service.create_document(
144
+ document_data,
145
+ file_info,
146
+ str(current_user.id)
147
+ )
148
+
149
+ return DocumentResponse(
150
+ id=str(document.id),
151
+ title=document.title,
152
+ description=document.description,
153
+ category=document.category,
154
+ file_name=document.file_name,
155
+ file_size=document.file_size,
156
+ mime_type=document.mime_type,
157
+ visibility=document.visibility,
158
+ is_viewable_only=document.is_viewable_only,
159
+ assigned_customers=document.assigned_customers,
160
+ tags=document.tags,
161
+ uploaded_by=document.uploaded_by,
162
+ created_at=document.created_at,
163
+ updated_at=document.updated_at
164
+ )
165
+
166
+ @router.get("/", response_model=List[DocumentResponse])
167
+ async def get_documents(
168
+ skip: int = 0,
169
+ limit: int = 100,
170
+ category: Optional[DocumentCategory] = None,
171
+ visibility: Optional[DocumentVisibility] = None,
172
+ search: Optional[str] = None,
173
+ current_user: User = Depends(get_current_active_user)
174
+ ):
175
+ """Get all documents accessible to the user"""
176
+ document_service = DocumentService()
177
+
178
+ documents = await document_service.get_documents(
179
+ skip=skip,
180
+ limit=limit,
181
+ category=category,
182
+ visibility=visibility,
183
+ search=search,
184
+ user_id=str(current_user.id),
185
+ user_role=current_user.role
186
+ )
187
+
188
+ return [
189
+ DocumentResponse(
190
+ id=str(doc.id),
191
+ title=doc.title,
192
+ description=doc.description,
193
+ category=doc.category,
194
+ visibility=doc.visibility,
195
+ is_viewable_only=doc.is_viewable_only,
196
+ assigned_customers=doc.assigned_customers,
197
+ tags=doc.tags,
198
+ file_name=doc.file_name,
199
+ file_size=doc.file_size,
200
+ mime_type=doc.mime_type,
201
+ uploaded_by=doc.uploaded_by,
202
+ created_at=doc.created_at,
203
+ updated_at=doc.updated_at
204
+ ) for doc in documents
205
+ ]
206
+
207
+ @router.get("/{document_id}", response_model=DocumentResponse)
208
+ async def get_document(
209
+ document_id: str,
210
+ current_user: User = Depends(get_current_active_user)
211
+ ):
212
+ """Get document by ID"""
213
+ document_service = DocumentService()
214
+
215
+ document = await document_service.get_document_by_id(document_id)
216
+ if not document:
217
+ raise HTTPException(status_code=404, detail="Document not found")
218
+
219
+ can_access_document(current_user, document)
220
+
221
+ return DocumentResponse(
222
+ id=str(document.id),
223
+ title=document.title,
224
+ description=document.description,
225
+ category=document.category,
226
+ visibility=document.visibility,
227
+ is_viewable_only=document.is_viewable_only,
228
+ assigned_customers=document.assigned_customers,
229
+ tags=document.tags,
230
+ file_name=document.file_name,
231
+ file_size=document.file_size,
232
+ mime_type=document.mime_type,
233
+ uploaded_by=document.uploaded_by,
234
+ created_at=document.created_at,
235
+ updated_at=document.updated_at
236
+ )
237
+
238
+ @router.get("/{document_id}/preview")
239
+ async def preview_document(
240
+ document_id: str,
241
+ current_user: User = Depends(get_current_active_user)
242
+ ):
243
+ """Preview document (view-only, always allowed)"""
244
+ document_service = DocumentService()
245
+
246
+ document = await document_service.get_document_by_id(document_id)
247
+ if not document:
248
+ raise HTTPException(status_code=404, detail="Document not found")
249
+
250
+ can_access_document(current_user, document)
251
+
252
+ if not os.path.exists(document.file_path):
253
+ raise HTTPException(status_code=404, detail="File not found")
254
+
255
+ # Ensure correct MIME type; fallback to guessed type if missing
256
+ media_type = document.mime_type or mimetypes.guess_type(document.file_name)[0] or "application/octet-stream"
257
+ return FileResponse(
258
+ path=document.file_path,
259
+ media_type=media_type,
260
+ headers={"Content-Disposition": "inline"}
261
+ )
262
+
263
+ @router.get("/{document_id}/public-url")
264
+ async def get_document_public_url(
265
+ document_id: str,
266
+ current_user: User = Depends(get_current_active_user)
267
+ ):
268
+ """Get a public URL for the document (for Office Online Viewer).
269
+ Only available for documents with PUBLIC visibility and when BASE_URL is not localhost.
270
+ """
271
+ document_service = DocumentService()
272
+
273
+ document = await document_service.get_document_by_id(document_id)
274
+ if not document:
275
+ raise HTTPException(status_code=404, detail="Document not found")
276
+
277
+ can_access_document(current_user, document)
278
+
279
+ # Only allow public URL for PUBLIC visibility documents
280
+ if document.visibility != DocumentVisibility.PUBLIC:
281
+ raise HTTPException(status_code=403, detail="Document must be PUBLIC to generate a public URL")
282
+
283
+ # Disallow localhost/private base URLs which Office Online Viewer cannot reach
284
+ base_url = settings.BASE_URL or ""
285
+ if any(host in base_url for host in ["localhost", "127.0.0.1", "0.0.0.0"]):
286
+ raise HTTPException(status_code=400, detail="BASE_URL must be a publicly accessible HTTPS URL for Office Viewer")
287
+
288
+ # Generate public URL endpoint
289
+ public_url = f"{base_url.rstrip('/')}/api/v1/documents/{document_id}/public-preview"
290
+
291
+ return {"url": public_url}
292
+
293
+ @router.get("/{document_id}/public-preview")
294
+ async def public_preview_document(document_id: str):
295
+ """Public preview endpoint for Office Online Viewer (no authentication required)"""
296
+ document_service = DocumentService()
297
+
298
+ document = await document_service.get_document_by_id(document_id)
299
+ if not document:
300
+ raise HTTPException(status_code=404, detail="Document not found")
301
+
302
+ # Only allow public access to documents with PUBLIC visibility
303
+ if document.visibility != DocumentVisibility.PUBLIC:
304
+ raise HTTPException(status_code=403, detail="Document is not publicly accessible")
305
+
306
+ if not os.path.exists(document.file_path):
307
+ raise HTTPException(status_code=404, detail="File not found")
308
+
309
+ # Ensure correct MIME type; fallback to guessed type if missing
310
+ media_type = document.mime_type or mimetypes.guess_type(document.file_name)[0] or "application/octet-stream"
311
+ return FileResponse(
312
+ path=document.file_path,
313
+ media_type=media_type,
314
+ headers={
315
+ "Content-Disposition": "inline",
316
+ "Access-Control-Allow-Origin": "*", # Allow CORS for Office Online Viewer
317
+ "Access-Control-Allow-Methods": "GET",
318
+ "Access-Control-Allow-Headers": "*"
319
+ }
320
+ )
321
+
322
+ @router.get("/{document_id}/download")
323
+ async def download_document(
324
+ document_id: str,
325
+ current_user: User = Depends(get_current_active_user)
326
+ ):
327
+ """Download document (blocked for view-only documents)"""
328
+ document_service = DocumentService()
329
+
330
+ document = await document_service.get_document_by_id(document_id)
331
+ if not document:
332
+ raise HTTPException(status_code=404, detail="Document not found")
333
+
334
+ can_access_document(current_user, document)
335
+
336
+ # Block download for view-only documents when accessed by customers
337
+ if document.is_viewable_only and current_user.role == UserRole.CUSTOMER:
338
+ raise HTTPException(status_code=403, detail="This document is view-only and cannot be downloaded")
339
+
340
+ if not os.path.exists(document.file_path):
341
+ raise HTTPException(status_code=404, detail="File not found")
342
+
343
+ return FileResponse(
344
+ path=document.file_path,
345
+ filename=document.file_name,
346
+ media_type=document.mime_type,
347
+ headers={"Content-Disposition": f"attachment; filename={document.file_name}"} # Force download
348
+ )
349
+
350
+ @router.put("/{document_id}", response_model=DocumentResponse)
351
+ async def update_document(
352
+ document_id: str,
353
+ document_update: DocumentUpdate,
354
+ current_user: User = Depends(get_current_active_user)
355
+ ):
356
+ """Update document (Staff only)"""
357
+ check_permission(current_user, [Permission.EDIT])
358
+
359
+ document_service = DocumentService()
360
+
361
+ document = await document_service.update_document(document_id, document_update)
362
+ if not document:
363
+ raise HTTPException(status_code=404, detail="Document not found")
364
+
365
+ return DocumentResponse(
366
+ id=str(document.id),
367
+ title=document.title,
368
+ description=document.description,
369
+ category=document.category,
370
+ visibility=document.visibility,
371
+ is_viewable_only=document.is_viewable_only,
372
+ assigned_customers=document.assigned_customers,
373
+ tags=document.tags,
374
+ file_name=document.file_name,
375
+ file_size=document.file_size,
376
+ mime_type=document.mime_type,
377
+ uploaded_by=document.uploaded_by,
378
+ created_at=document.created_at,
379
+ updated_at=document.updated_at
380
+ )
381
+
382
+ @router.delete("/{document_id}")
383
+ async def delete_document(
384
+ document_id: str,
385
+ current_user: User = Depends(get_current_active_user)
386
+ ):
387
+ """Delete document (Staff only)"""
388
+ check_permission(current_user, [Permission.DELETE])
389
+
390
+ document_service = DocumentService()
391
+
392
+ success = await document_service.delete_document(document_id)
393
+ if not success:
394
+ raise HTTPException(status_code=404, detail="Document not found")
395
+
396
+ return {"message": "Document deleted successfully"}
metsa-backend/app/routers/notifications.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from ..schemas.notification import NotificationResponse
4
+ from ..models.user import User
5
+ from ..services.notification import NotificationService
6
+ from ..utils.security import get_current_active_user
7
+
8
+ router = APIRouter(prefix="/notifications", tags=["notifications"])
9
+
10
+ @router.get("/", response_model=List[NotificationResponse])
11
+ async def get_notifications(
12
+ skip: int = 0,
13
+ limit: int = 50,
14
+ unread_only: bool = False,
15
+ current_user: User = Depends(get_current_active_user)
16
+ ):
17
+ """Get user notifications"""
18
+ notification_service = NotificationService()
19
+
20
+ notifications = await notification_service.get_user_notifications(
21
+ str(current_user.id),
22
+ skip=skip,
23
+ limit=limit,
24
+ unread_only=unread_only
25
+ )
26
+
27
+ return [
28
+ NotificationResponse(
29
+ id=str(notif.id),
30
+ type=notif.type,
31
+ title=notif.title,
32
+ message=notif.message,
33
+ document_id=notif.document_id,
34
+ user_id=notif.user_id,
35
+ is_read=notif.is_read,
36
+ created_at=notif.created_at
37
+ ) for notif in notifications
38
+ ]
39
+
40
+ @router.put("/{notification_id}/read")
41
+ async def mark_notification_as_read(
42
+ notification_id: str,
43
+ current_user: User = Depends(get_current_active_user)
44
+ ):
45
+ """Mark notification as read"""
46
+ notification_service = NotificationService()
47
+
48
+ success = await notification_service.mark_as_read(notification_id, str(current_user.id))
49
+ if not success:
50
+ raise HTTPException(status_code=404, detail="Notification not found")
51
+
52
+ return {"message": "Notification marked as read"}
53
+
54
+ @router.put("/read-all")
55
+ async def mark_all_notifications_as_read(
56
+ current_user: User = Depends(get_current_active_user)
57
+ ):
58
+ """Mark all notifications as read"""
59
+ notification_service = NotificationService()
60
+
61
+ count = await notification_service.mark_all_as_read(str(current_user.id))
62
+ return {"message": f"{count} notifications marked as read"}
metsa-backend/app/routers/users.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, status
3
+ from ..schemas.user import UserCreate, UserUpdate, UserResponse
4
+ from ..models.user import User, UserRole
5
+ from ..services.user import UserService
6
+ from ..utils.security import get_current_active_user
7
+ from ..utils.permissions import is_admin
8
+
9
+ router = APIRouter(prefix="/users", tags=["users"])
10
+
11
+ @router.post("/", response_model=UserResponse)
12
+ async def create_user(
13
+ user_data: UserCreate,
14
+ current_user: User = Depends(get_current_active_user)
15
+ ):
16
+ """Create a new user (Admin only)"""
17
+ is_admin(current_user)
18
+
19
+ user_service = UserService()
20
+
21
+ try:
22
+ user = await user_service.create_user(user_data, str(current_user.id))
23
+ return UserResponse(
24
+ id=str(user.id),
25
+ email=user.email,
26
+ username=user.username,
27
+ role=user.role,
28
+ permissions=user.permissions,
29
+ customer_info=user.customer_info,
30
+ is_active=user.is_active,
31
+ is_verified=user.is_verified,
32
+ created_at=user.created_at,
33
+ updated_at=user.updated_at,
34
+ last_login=user.last_login
35
+ )
36
+ except ValueError as e:
37
+ raise HTTPException(status_code=400, detail=str(e))
38
+
39
+ @router.get("/me", response_model=UserResponse)
40
+ async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
41
+ """Get current user information"""
42
+ return UserResponse(
43
+ id=str(current_user.id),
44
+ email=current_user.email,
45
+ username=current_user.username,
46
+ role=current_user.role,
47
+ permissions=current_user.permissions,
48
+ customer_info=current_user.customer_info,
49
+ is_active=current_user.is_active,
50
+ is_verified=current_user.is_verified,
51
+ created_at=current_user.created_at,
52
+ updated_at=current_user.updated_at,
53
+ last_login=current_user.last_login
54
+ )
55
+
56
+ @router.get("/", response_model=List[UserResponse])
57
+ async def get_users(
58
+ skip: int = 0,
59
+ limit: int = 100,
60
+ role: Optional[UserRole] = None,
61
+ current_user: User = Depends(get_current_active_user)
62
+ ):
63
+ """Get all users (Admin only)"""
64
+ is_admin(current_user)
65
+
66
+ user_service = UserService()
67
+
68
+ users = await user_service.get_users(skip=skip, limit=limit, role=role)
69
+ return [
70
+ UserResponse(
71
+ id=str(user.id),
72
+ email=user.email,
73
+ username=user.username,
74
+ role=user.role,
75
+ permissions=user.permissions,
76
+ customer_info=user.customer_info,
77
+ is_active=user.is_active,
78
+ is_verified=user.is_verified,
79
+ created_at=user.created_at,
80
+ updated_at=user.updated_at,
81
+ last_login=user.last_login
82
+ ) for user in users
83
+ ]
84
+
85
+ @router.get("/{user_id}", response_model=UserResponse)
86
+ async def get_user(
87
+ user_id: str,
88
+ current_user: User = Depends(get_current_active_user)
89
+ ):
90
+ """Get user by ID (Admin only)"""
91
+ is_admin(current_user)
92
+
93
+ user_service = UserService()
94
+
95
+ user = await user_service.get_user_by_id(user_id)
96
+ if not user:
97
+ raise HTTPException(status_code=404, detail="User not found")
98
+
99
+ return UserResponse(
100
+ id=str(user.id),
101
+ email=user.email,
102
+ username=user.username,
103
+ role=user.role,
104
+ permissions=user.permissions,
105
+ customer_info=user.customer_info,
106
+ is_active=user.is_active,
107
+ is_verified=user.is_verified,
108
+ created_at=user.created_at,
109
+ updated_at=user.updated_at,
110
+ last_login=user.last_login
111
+ )
112
+
113
+ @router.put("/{user_id}", response_model=UserResponse)
114
+ async def update_user(
115
+ user_id: str,
116
+ user_update: UserUpdate,
117
+ current_user: User = Depends(get_current_active_user)
118
+ ):
119
+ """Update user (Admin only)"""
120
+ is_admin(current_user)
121
+
122
+ user_service = UserService()
123
+
124
+ user = await user_service.update_user(user_id, user_update)
125
+ if not user:
126
+ raise HTTPException(status_code=404, detail="User not found")
127
+
128
+ return UserResponse(
129
+ id=str(user.id),
130
+ email=user.email,
131
+ username=user.username,
132
+ role=user.role,
133
+ permissions=user.permissions,
134
+ customer_info=user.customer_info,
135
+ is_active=user.is_active,
136
+ is_verified=user.is_verified,
137
+ created_at=user.created_at,
138
+ updated_at=user.updated_at,
139
+ last_login=user.last_login
140
+ )
141
+
142
+ @router.delete("/{user_id}")
143
+ async def delete_user(
144
+ user_id: str,
145
+ current_user: User = Depends(get_current_active_user)
146
+ ):
147
+ """Delete user (Admin only)"""
148
+ is_admin(current_user)
149
+
150
+ if str(current_user.id) == user_id:
151
+ raise HTTPException(status_code=400, detail="Cannot delete yourself")
152
+
153
+ user_service = UserService()
154
+
155
+ success = await user_service.delete_user(user_id)
156
+ if not success:
157
+ raise HTTPException(status_code=404, detail="User not found")
158
+
159
+ return {"message": "User deleted successfully"}
metsa-backend/app/schemas/__init__.py ADDED
File without changes
metsa-backend/app/schemas/document.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, List
2
+ from datetime import datetime
3
+ from pydantic import BaseModel
4
+ from ..models.document import DocumentCategory, DocumentVisibility
5
+
6
+ class DocumentBase(BaseModel):
7
+ title: str
8
+ description: Optional[str] = None
9
+ category: DocumentCategory
10
+ visibility: DocumentVisibility = DocumentVisibility.PRIVATE
11
+ is_viewable_only: bool = False
12
+ assigned_customers: List[str] = []
13
+ tags: List[str] = []
14
+
15
+ class DocumentCreate(DocumentBase):
16
+ pass
17
+
18
+ class DocumentUpdate(BaseModel):
19
+ title: Optional[str] = None
20
+ description: Optional[str] = None
21
+ category: Optional[DocumentCategory] = None
22
+ visibility: Optional[DocumentVisibility] = None
23
+ is_viewable_only: Optional[bool] = None
24
+ assigned_customers: Optional[List[str]] = None
25
+ tags: Optional[List[str]] = None
26
+
27
+ class DocumentResponse(DocumentBase):
28
+ id: str
29
+ file_name: str
30
+ file_size: int
31
+ mime_type: str
32
+ uploaded_by: str
33
+ created_at: datetime
34
+ updated_at: datetime
metsa-backend/app/schemas/notification.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from datetime import datetime
3
+ from pydantic import BaseModel
4
+ from ..models.notification import NotificationType
5
+
6
+ class NotificationBase(BaseModel):
7
+ type: NotificationType
8
+ title: str
9
+ message: str
10
+ document_id: Optional[str] = None
11
+
12
+ class NotificationCreate(NotificationBase):
13
+ user_id: str
14
+
15
+ class NotificationResponse(NotificationBase):
16
+ id: str
17
+ user_id: str
18
+ is_read: bool
19
+ created_at: datetime
metsa-backend/app/schemas/user.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, List
2
+ from datetime import datetime
3
+ from pydantic import BaseModel, EmailStr
4
+ from ..models.user import UserRole, Permission
5
+
6
+ class UserBase(BaseModel):
7
+ email: EmailStr
8
+ username: str
9
+ role: UserRole
10
+ permissions: List[Permission] = []
11
+ customer_info: Optional[dict] = None
12
+
13
+ class UserCreate(UserBase):
14
+ password: str
15
+
16
+ class UserUpdate(BaseModel):
17
+ email: Optional[EmailStr] = None
18
+ username: Optional[str] = None
19
+ role: Optional[UserRole] = None
20
+ permissions: Optional[List[Permission]] = None
21
+ is_active: Optional[bool] = None
22
+ customer_info: Optional[dict] = None
23
+
24
+ class UserResponse(UserBase):
25
+ id: str
26
+ is_active: bool
27
+ is_verified: bool
28
+ created_at: datetime
29
+ updated_at: datetime
30
+ last_login: Optional[datetime] = None
31
+
32
+ class UserLogin(BaseModel):
33
+ username: str
34
+ password: str
35
+
36
+ class Token(BaseModel):
37
+ access_token: str
38
+ token_type: str = "bearer"
39
+
40
+ class TokenData(BaseModel):
41
+ username: Optional[str] = None
42
+ user_id: Optional[str] = None
43
+ role: Optional[str] = None
44
+
45
+ class PasswordChange(BaseModel):
46
+ current_password: str
47
+ new_password: str
metsa-backend/app/services/__init__.py ADDED
File without changes
metsa-backend/app/services/auth.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional
3
+ from jose import JWTError, jwt
4
+ from passlib.context import CryptContext
5
+ from ..config import settings
6
+ from ..models.user import User
7
+ from ..database import get_database
8
+
9
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
10
+
11
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
12
+ return pwd_context.verify(plain_password, hashed_password)
13
+
14
+ def get_password_hash(password: str) -> str:
15
+ return pwd_context.hash(password)
16
+
17
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
18
+ to_encode = data.copy()
19
+ if expires_delta:
20
+ expire = datetime.utcnow() + expires_delta
21
+ else:
22
+ expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
23
+ to_encode.update({"exp": expire})
24
+ encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
25
+ return encoded_jwt
26
+
27
+ async def authenticate_user(username: str, password: str):
28
+ db = get_database()
29
+ user_dict = await db.users.find_one({"username": username})
30
+ if not user_dict:
31
+ return False
32
+ user = User.model_validate(user_dict)
33
+ if not verify_password(password, user.hashed_password):
34
+ return False
35
+ return user
metsa-backend/app/services/document.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from bson import ObjectId
3
+ from datetime import datetime
4
+ import os
5
+ import mimetypes
6
+ import aiofiles
7
+ from ..database import get_database
8
+ from ..models.document import Document, DocumentVisibility, DocumentCategory
9
+ from ..models.user import UserRole
10
+ from ..schemas.document import DocumentCreate, DocumentUpdate
11
+ from ..config import settings
12
+ from .notification import NotificationService
13
+
14
+ class DocumentService:
15
+ def __init__(self):
16
+ self.db = get_database()
17
+ self.notification_service = NotificationService()
18
+
19
+ async def create_document(self, document_data: DocumentCreate, file_info: dict, uploaded_by: str) -> Document:
20
+ document_dict = document_data.model_dump()
21
+ document_dict.update({
22
+ "file_path": file_info["file_path"],
23
+ "file_name": file_info["file_name"],
24
+ "file_size": file_info["file_size"],
25
+ "mime_type": file_info["mime_type"],
26
+ "uploaded_by": uploaded_by,
27
+ "created_at": datetime.utcnow(),
28
+ "updated_at": datetime.utcnow()
29
+ })
30
+
31
+ result = await self.db.documents.insert_one(document_dict)
32
+ document_dict["_id"] = result.inserted_id
33
+
34
+ # Send notifications to assigned customers
35
+ if document_data.assigned_customers:
36
+ for customer_id in document_data.assigned_customers:
37
+ await self.notification_service.create_notification(
38
+ user_id=customer_id,
39
+ type="new_document",
40
+ title="New Document Available",
41
+ message=f"A new document '{document_data.title}' has been uploaded for you.",
42
+ document_id=str(result.inserted_id)
43
+ )
44
+
45
+ return Document.model_validate(document_dict)
46
+
47
+ async def get_document_by_id(self, document_id: str) -> Optional[Document]:
48
+ document_dict = await self.db.documents.find_one({"_id": ObjectId(document_id)})
49
+ return Document.model_validate(document_dict) if document_dict else None
50
+
51
+ async def get_documents(self, skip: int = 0, limit: int = 100,
52
+ category: Optional[DocumentCategory] = None,
53
+ visibility: Optional[DocumentVisibility] = None,
54
+ search: Optional[str] = None,
55
+ user_id: Optional[str] = None,
56
+ user_role: Optional[UserRole] = None) -> List[Document]:
57
+ query: dict = {}
58
+
59
+ if category:
60
+ query["category"] = category.value if isinstance(category, DocumentCategory) else category
61
+ if visibility:
62
+ query["visibility"] = visibility.value if isinstance(visibility, DocumentVisibility) else visibility
63
+ if search:
64
+ query["$or"] = [
65
+ {"title": {"$regex": search, "$options": "i"}},
66
+ {"description": {"$regex": search, "$options": "i"}},
67
+ {"tags": {"$elemMatch": {"$regex": search, "$options": "i"}}}
68
+ ]
69
+
70
+ # Filter based on user role
71
+ if user_role == UserRole.CUSTOMER:
72
+ user_access = {
73
+ "$or": [
74
+ {"visibility": DocumentVisibility.PUBLIC.value},
75
+ {"assigned_customers": user_id}
76
+ ]
77
+ }
78
+ if "$or" in query:
79
+ query = {"$and": [user_access, {"$or": query["$or"]}]}
80
+ else:
81
+ query.update(user_access)
82
+
83
+ cursor = self.db.documents.find(query).skip(skip).limit(limit)
84
+ documents = []
85
+ async for document_dict in cursor:
86
+ documents.append(Document.model_validate(document_dict))
87
+ return documents
88
+
89
+ async def update_document(self, document_id: str, document_update: DocumentUpdate) -> Optional[Document]:
90
+ update_data = {k: v for k, v in document_update.model_dump().items() if v is not None}
91
+ if update_data:
92
+ update_data["updated_at"] = datetime.utcnow()
93
+
94
+ # Get old document for comparison
95
+ old_doc = await self.get_document_by_id(document_id)
96
+
97
+ result = await self.db.documents.update_one(
98
+ {"_id": ObjectId(document_id)},
99
+ {"$set": update_data}
100
+ )
101
+
102
+ if result.modified_count:
103
+ # Send notifications if assigned customers changed
104
+ if "assigned_customers" in update_data:
105
+ new_customers = set(update_data["assigned_customers"]) - set(old_doc.assigned_customers)
106
+ for customer_id in new_customers:
107
+ await self.notification_service.create_notification(
108
+ user_id=customer_id,
109
+ type="new_document",
110
+ title="Document Assigned",
111
+ message=f"Document '{old_doc.title}' has been assigned to you.",
112
+ document_id=document_id
113
+ )
114
+
115
+ return await self.get_document_by_id(document_id)
116
+ return None
117
+
118
+ async def delete_document(self, document_id: str) -> bool:
119
+ # Get document to delete file
120
+ document = await self.get_document_by_id(document_id)
121
+ if document:
122
+ # Delete file from filesystem
123
+ try:
124
+ os.remove(document.file_path)
125
+ except:
126
+ pass
127
+
128
+ # Delete from database
129
+ result = await self.db.documents.delete_one({"_id": ObjectId(document_id)})
130
+ return result.deleted_count > 0
131
+ return False
132
+
133
+ async def save_file(self, file_content: bytes, filename: str, category: DocumentCategory | str) -> dict:
134
+ # Normalize category to its string value
135
+ category_value = category.value if isinstance(category, DocumentCategory) else category
136
+
137
+ # Create directory if not exists
138
+ upload_dir = os.path.join(settings.UPLOAD_DIR, category_value)
139
+ os.makedirs(upload_dir, exist_ok=True)
140
+
141
+ # Generate unique filename
142
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
143
+ safe_filename = f"{timestamp}_{filename}"
144
+ file_path = os.path.join(upload_dir, safe_filename)
145
+
146
+ # Save file
147
+ async with aiofiles.open(file_path, 'wb') as f:
148
+ await f.write(file_content)
149
+
150
+ # Try to detect a better MIME type from the filename extension
151
+ guessed_mime, _ = mimetypes.guess_type(filename)
152
+ mime_type = guessed_mime or "application/octet-stream"
153
+ return {
154
+ "file_path": file_path,
155
+ "file_name": filename,
156
+ "file_size": len(file_content),
157
+ "mime_type": mime_type
158
+ }
metsa-backend/app/services/email.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import smtplib
2
+ from email.mime.text import MIMEText
3
+ from email.mime.multipart import MIMEMultipart
4
+ from typing import List
5
+ from ..config import settings
6
+
7
+ async def send_email(to_email: str, subject: str, body: str, is_html: bool = False):
8
+ """Send email using SMTP"""
9
+ if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
10
+ print(f"Email sending skipped (not configured): {subject} to {to_email}")
11
+ return
12
+
13
+ msg = MIMEMultipart()
14
+ msg['From'] = settings.EMAILS_FROM_EMAIL or settings.SMTP_USER
15
+ msg['To'] = to_email
16
+ msg['Subject'] = subject
17
+
18
+ msg.attach(MIMEText(body, 'html' if is_html else 'plain'))
19
+
20
+ try:
21
+ server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
22
+ server.starttls()
23
+ server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
24
+ server.send_message(msg)
25
+ server.quit()
26
+ except Exception as e:
27
+ print(f"Failed to send email: {e}")
28
+
29
+ async def send_welcome_email(email: str, username: str):
30
+ subject = "Welcome to Metsa Document Portal"
31
+ body = f"""
32
+ <h2>Welcome to Metsa Document Portal</h2>
33
+ <p>Hello {username},</p>
34
+ <p>Your account has been created successfully. You can now log in to access your documents.</p>
35
+ <p>Username: {username}</p>
36
+ <p>Please contact your administrator if you need to reset your password.</p>
37
+ <br>
38
+ <p>Best regards,<br>Metsa Team</p>
39
+ """
40
+ await send_email(email, subject, body, is_html=True)
41
+
42
+ async def send_password_reset_email(email: str, reset_token: str):
43
+ subject = "Password Reset Request"
44
+ body = f"""
45
+ <h2>Password Reset Request</h2>
46
+ <p>You have requested to reset your password. Click the link below to reset it:</p>
47
+ <p><a href="http://portal.metsa.com/reset-password?token={reset_token}">Reset Password</a></p>
48
+ <p>This link will expire in 1 hour.</p>
49
+ <p>If you didn't request this, please ignore this email.</p>
50
+ <br>
51
+ <p>Best regards,<br>Metsa Team</p>
52
+ """
53
+ await send_email(email, subject, body, is_html=True)
metsa-backend/app/services/notification.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from bson import ObjectId
3
+ from datetime import datetime
4
+ from ..database import get_database
5
+ from ..models.notification import Notification, NotificationType
6
+
7
+ class NotificationService:
8
+ def __init__(self):
9
+ self.db = get_database()
10
+
11
+ async def create_notification(self, user_id: str, type: str, title: str,
12
+ message: str, document_id: Optional[str] = None) -> Notification:
13
+ notification_dict = {
14
+ "user_id": user_id,
15
+ "type": type,
16
+ "title": title,
17
+ "message": message,
18
+ "document_id": document_id,
19
+ "is_read": False,
20
+ "created_at": datetime.utcnow()
21
+ }
22
+
23
+ result = await self.db.notifications.insert_one(notification_dict)
24
+ notification_dict["_id"] = result.inserted_id
25
+
26
+ return Notification.model_validate(notification_dict)
27
+
28
+ async def get_user_notifications(self, user_id: str, skip: int = 0,
29
+ limit: int = 50, unread_only: bool = False) -> List[Notification]:
30
+ query = {"user_id": user_id}
31
+ if unread_only:
32
+ query["is_read"] = False
33
+
34
+ cursor = self.db.notifications.find(query).sort("created_at", -1).skip(skip).limit(limit)
35
+ notifications = []
36
+ async for notification_dict in cursor:
37
+ notifications.append(Notification.model_validate(notification_dict))
38
+ return notifications
39
+
40
+ async def mark_as_read(self, notification_id: str, user_id: str) -> bool:
41
+ result = await self.db.notifications.update_one(
42
+ {"_id": ObjectId(notification_id), "user_id": user_id},
43
+ {"$set": {"is_read": True}}
44
+ )
45
+ return result.modified_count > 0
46
+
47
+ async def mark_all_as_read(self, user_id: str) -> int:
48
+ result = await self.db.notifications.update_many(
49
+ {"user_id": user_id, "is_read": False},
50
+ {"$set": {"is_read": True}}
51
+ )
52
+ return result.modified_count
metsa-backend/app/services/user.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from bson import ObjectId
3
+ from datetime import datetime
4
+ from ..database import get_database
5
+ from ..models.user import User, UserRole, Permission
6
+ from ..schemas.user import UserCreate, UserUpdate
7
+ from .auth import get_password_hash, verify_password
8
+ from .email import send_welcome_email
9
+
10
+ class UserService:
11
+ def __init__(self):
12
+ self.db = get_database()
13
+
14
+ async def create_user(self, user_data: UserCreate, created_by: str) -> User:
15
+ # Check if user already exists
16
+ existing_user = await self.db.users.find_one({
17
+ "$or": [
18
+ {"email": user_data.email},
19
+ {"username": user_data.username}
20
+ ]
21
+ })
22
+ if existing_user:
23
+ raise ValueError("User with this email or username already exists")
24
+
25
+ # Create user
26
+ user_dict = user_data.model_dump()
27
+ user_dict["hashed_password"] = get_password_hash(user_dict.pop("password"))
28
+ user_dict["created_by"] = created_by
29
+ user_dict["created_at"] = datetime.utcnow()
30
+ user_dict["updated_at"] = datetime.utcnow()
31
+
32
+ # Set default permissions based on role
33
+ if user_data.role == UserRole.ADMIN:
34
+ user_dict["permissions"] = [p.value for p in Permission]
35
+ elif user_data.role == UserRole.EDITOR:
36
+ user_dict["permissions"] = user_data.permissions or [Permission.VIEW, Permission.UPLOAD, Permission.EDIT]
37
+ else:
38
+ user_dict["permissions"] = [Permission.VIEW]
39
+
40
+ result = await self.db.users.insert_one(user_dict)
41
+ user_dict["_id"] = result.inserted_id
42
+
43
+ # Send welcome email
44
+ await send_welcome_email(user_data.email, user_data.username)
45
+
46
+ return User.model_validate(user_dict)
47
+
48
+ async def get_user_by_id(self, user_id: str) -> Optional[User]:
49
+ user_dict = await self.db.users.find_one({"_id": ObjectId(user_id)})
50
+ return User.model_validate(user_dict) if user_dict else None
51
+
52
+ async def get_user_by_username(self, username: str) -> Optional[User]:
53
+ user_dict = await self.db.users.find_one({"username": username})
54
+ return User.model_validate(user_dict) if user_dict else None
55
+
56
+ async def get_users(self, skip: int = 0, limit: int = 100, role: Optional[UserRole] = None) -> List[User]:
57
+ query = {}
58
+ if role:
59
+ query["role"] = role
60
+
61
+ cursor = self.db.users.find(query).skip(skip).limit(limit)
62
+ users = []
63
+ async for user_dict in cursor:
64
+ users.append(User.model_validate(user_dict))
65
+ return users
66
+
67
+ async def update_user(self, user_id: str, user_update: UserUpdate) -> Optional[User]:
68
+ update_data = {k: v for k, v in user_update.model_dump().items() if v is not None}
69
+ if update_data:
70
+ update_data["updated_at"] = datetime.utcnow()
71
+ result = await self.db.users.update_one(
72
+ {"_id": ObjectId(user_id)},
73
+ {"$set": update_data}
74
+ )
75
+ if result.modified_count:
76
+ return await self.get_user_by_id(user_id)
77
+ return None
78
+
79
+ async def delete_user(self, user_id: str) -> bool:
80
+ result = await self.db.users.delete_one({"_id": ObjectId(user_id)})
81
+ return result.deleted_count > 0
82
+
83
+ async def update_last_login(self, user_id: str):
84
+ await self.db.users.update_one(
85
+ {"_id": ObjectId(user_id)},
86
+ {"$set": {"last_login": datetime.utcnow()}}
87
+ )
88
+
89
+ async def change_password(self, user_id: str, current_password: str, new_password: str) -> bool:
90
+ """Change user password after verifying current password"""
91
+ # Get user
92
+ user = await self.get_user_by_id(user_id)
93
+ if not user:
94
+ return False
95
+
96
+ # Verify current password
97
+ if not verify_password(current_password, user.hashed_password):
98
+ return False
99
+
100
+ # Update password
101
+ new_hashed_password = get_password_hash(new_password)
102
+ result = await self.db.users.update_one(
103
+ {"_id": ObjectId(user_id)},
104
+ {"$set": {
105
+ "hashed_password": new_hashed_password,
106
+ "updated_at": datetime.utcnow()
107
+ }}
108
+ )
109
+
110
+ return result.modified_count > 0
metsa-backend/app/utils/__init__.py ADDED
File without changes
metsa-backend/app/utils/permissions.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from fastapi import HTTPException, status
3
+ from ..models.user import User, UserRole, Permission
4
+ from ..models.document import DocumentVisibility
5
+
6
+ def check_permission(user: User, required_permissions: List[Permission]):
7
+ """Check if user has required permissions"""
8
+ if user.role == UserRole.ADMIN:
9
+ return True
10
+
11
+ user_permissions = set(user.permissions)
12
+ required_permissions_set = set(required_permissions)
13
+
14
+ if not required_permissions_set.issubset(user_permissions):
15
+ raise HTTPException(
16
+ status_code=status.HTTP_403_FORBIDDEN,
17
+ detail="Not enough permissions"
18
+ )
19
+
20
+ return True
21
+
22
+ def is_admin(user: User):
23
+ """Check if user is admin"""
24
+ if user.role != UserRole.ADMIN:
25
+ raise HTTPException(
26
+ status_code=status.HTTP_403_FORBIDDEN,
27
+ detail="Admin access required"
28
+ )
29
+ return True
30
+
31
+ def is_staff(user: User):
32
+ """Check if user is admin or editor"""
33
+ if user.role not in [UserRole.ADMIN, UserRole.EDITOR]:
34
+ raise HTTPException(
35
+ status_code=status.HTTP_403_FORBIDDEN,
36
+ detail="Staff access required"
37
+ )
38
+ return True
39
+
40
+ def can_access_document(user: User, document):
41
+ """Check if user can access a document"""
42
+ if user.role in [UserRole.ADMIN, UserRole.EDITOR]:
43
+ return True
44
+
45
+ if document.visibility == DocumentVisibility.PUBLIC:
46
+ return True
47
+
48
+ if str(user.id) in document.assigned_customers:
49
+ return True
50
+
51
+ raise HTTPException(
52
+ status_code=status.HTTP_403_FORBIDDEN,
53
+ detail="You don't have access to this document"
54
+ )
metsa-backend/app/utils/security.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from datetime import datetime, timedelta
3
+ from fastapi import Depends, HTTPException, status
4
+ from fastapi.security import OAuth2PasswordBearer
5
+ from jose import JWTError, jwt
6
+ from ..config import settings
7
+ from ..schemas.user import TokenData
8
+ from ..services.user import UserService
9
+
10
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
11
+
12
+ async def get_current_user(token: str = Depends(oauth2_scheme)):
13
+ credentials_exception = HTTPException(
14
+ status_code=status.HTTP_401_UNAUTHORIZED,
15
+ detail="Could not validate credentials",
16
+ headers={"WWW-Authenticate": "Bearer"},
17
+ )
18
+
19
+ try:
20
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
21
+ username: str = payload.get("sub")
22
+ user_id: str = payload.get("user_id")
23
+ role: str = payload.get("role")
24
+
25
+ if username is None or user_id is None:
26
+ raise credentials_exception
27
+
28
+ token_data = TokenData(username=username, user_id=user_id, role=role)
29
+ except JWTError:
30
+ raise credentials_exception
31
+
32
+ user_service = UserService()
33
+ user = await user_service.get_user_by_username(username=token_data.username)
34
+
35
+ if user is None:
36
+ raise credentials_exception
37
+
38
+ if not user.is_active:
39
+ raise HTTPException(
40
+ status_code=status.HTTP_400_BAD_REQUEST,
41
+ detail="Inactive user"
42
+ )
43
+
44
+ return user
45
+
46
+ async def get_current_active_user(current_user = Depends(get_current_user)):
47
+ if not current_user.is_active:
48
+ raise HTTPException(status_code=400, detail="Inactive user")
49
+ return current_user
metsa-backend/documentation.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import fnmatch
3
+ from pathlib import Path
4
+
5
+ def should_skip_file(filepath, filename):
6
+ """Check if file should be skipped based on patterns"""
7
+ skip_patterns = [
8
+ '__pycache__',
9
+ '*.pyc',
10
+ '.env*',
11
+ '*.log',
12
+ '.git*',
13
+ '*.tmp',
14
+ '*.temp',
15
+ 'node_modules',
16
+ '.vscode',
17
+ '.idea',
18
+ '*.DS_Store',
19
+ 'Thumbs.db'
20
+ ]
21
+
22
+ skip_dirs = [
23
+ '__pycache__',
24
+ '.git',
25
+ 'node_modules',
26
+ '.vscode',
27
+ '.idea',
28
+ 'venv',
29
+ 'env',
30
+ '.env'
31
+ ]
32
+
33
+ # Check if any parent directory should be skipped
34
+ path_parts = Path(filepath).parts
35
+ for part in path_parts:
36
+ if part in skip_dirs:
37
+ return True
38
+
39
+ # Check if filename matches skip patterns
40
+ for pattern in skip_patterns:
41
+ if fnmatch.fnmatch(filename, pattern):
42
+ return True
43
+
44
+ return False
45
+
46
+ def get_file_content(filepath):
47
+ """Read file content safely"""
48
+ try:
49
+ with open(filepath, 'r', encoding='utf-8') as file:
50
+ return file.read()
51
+ except UnicodeDecodeError:
52
+ try:
53
+ with open(filepath, 'r', encoding='latin-1') as file:
54
+ return file.read()
55
+ except Exception as e:
56
+ return f"Error reading file: {str(e)}"
57
+ except Exception as e:
58
+ return f"Error reading file: {str(e)}"
59
+
60
+ def generate_documentation(root_path='.', output_file='PROJECT_DOCUMENTATION.md'):
61
+ """Generate complete project documentation"""
62
+
63
+ documentation = []
64
+ documentation.append("# Project Documentation\n")
65
+ documentation.append(f"Generated from: {os.path.abspath(root_path)}\n")
66
+ documentation.append("---\n")
67
+
68
+ # Get all Python files
69
+ python_files = []
70
+
71
+ for root, dirs, files in os.walk(root_path):
72
+ # Remove directories that should be skipped
73
+ dirs[:] = [d for d in dirs if not should_skip_file(os.path.join(root, d), d)]
74
+
75
+ for file in files:
76
+ filepath = os.path.join(root, file)
77
+
78
+ # Skip system files and focus on Python files
79
+ if should_skip_file(filepath, file):
80
+ continue
81
+
82
+ # Only include Python files and important config files
83
+ if file.endswith('.py') or file in ['requirements.txt', 'README.md', '.env.example']:
84
+ python_files.append(filepath)
85
+
86
+ # Sort files for consistent output
87
+ python_files.sort()
88
+
89
+ # Generate documentation for each file
90
+ for filepath in python_files:
91
+ relative_path = os.path.relpath(filepath, root_path)
92
+
93
+ # Add file header
94
+ documentation.append(f"## {relative_path}\n")
95
+
96
+ # Get file content
97
+ content = get_file_content(filepath)
98
+
99
+ # Add code block with syntax highlighting
100
+ if filepath.endswith('.py'):
101
+ documentation.append("```python")
102
+ elif filepath.endswith('.md'):
103
+ documentation.append("```markdown")
104
+ elif filepath.endswith('.txt'):
105
+ documentation.append("```text")
106
+ else:
107
+ documentation.append("```")
108
+
109
+ documentation.append(content)
110
+ documentation.append("```\n")
111
+ documentation.append("---\n")
112
+
113
+ # Write documentation to file
114
+ with open(output_file, 'w', encoding='utf-8') as f:
115
+ f.write('\n'.join(documentation))
116
+
117
+ print(f"Documentation generated successfully: {output_file}")
118
+ print(f"Total files documented: {len(python_files)}")
119
+
120
+ def main():
121
+ """Main function to run the documentation generator"""
122
+ # You can modify these paths as needed
123
+ project_root = "." # Current directory
124
+ output_filename = "PROJECT_DOCUMENTATION.md"
125
+
126
+ print("Starting documentation generation...")
127
+ print(f"Project root: {os.path.abspath(project_root)}")
128
+ print(f"Output file: {output_filename}")
129
+ print("=" * 50)
130
+
131
+ generate_documentation(project_root, output_filename)
132
+
133
+ print("=" * 50)
134
+ print("Documentation generation completed!")
135
+
136
+ if __name__ == "__main__":
137
+ main()
metsa-backend/requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn==0.24.0
3
+ pymongo==4.5.0
4
+ motor==3.3.2
5
+ python-jose[cryptography]==3.3.0
6
+ passlib[bcrypt]==1.7.4
7
+ python-multipart==0.0.6
8
+ python-dotenv==1.0.0
9
+ pydantic==2.4.2
10
+ pydantic[email]==2.4.2
11
+ pydantic-settings==2.0.3
12
+ aiofiles==23.2.1
13
+ python-dateutil==2.8.2
metsa-backend/uploads/commercial/20250818_165924_Predicting Chronic Heart Failure After Myocardial Infarction Using Machine Learning Techniques (updated).docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8820c1d54c416d640cbdc3df0ff0ba986e5843f9e3ae4125ca997def01e22e92
3
+ size 1033249
metsa-backend/uploads/compliance/20250718_181330_c4611_sample_explain.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2353deadc9fa87817413b239841a373fb51de8b74a75d3c8c863de735ca0b4a0
3
+ size 88226
metsa-backend/uploads/other/20250818_190139_MidTerm Exam Schedule Fall-2024.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b1fce9887bc4c3aef4d1ed8d1fe0a86535d0c4cc7fb4d00ea55c0bbda62649eb
3
+ size 399628
metsa-backend/uploads/other/20250818_210114_FYP_Report_Updated (1).docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b6a6a7deae9e2d09cf68bb1f2859cb2cfc77512cbe5f72c1395894b6125af687
3
+ size 204944
metsa-backend/uploads/other/20250818_210611_Paper+5+598+25-4-24-P-27-32.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ecb0fab640f1469ead487048768a725a514ae3ae70051829fa5c973b3bde38ef
3
+ size 457320
metsa-frontend ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 9ed117f3565f5bfb126c45f9bfe3f04e76d16152
scripts/build_frontend.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd /app/metsa-frontend
5
+ export NEXT_TELEMETRY_DISABLED=1
6
+ npm ci
7
+ npm run build
8
+ npm run export
9
+
scripts/start.sh ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Activate Python venv
5
+ source /app/venv/bin/activate
6
+
7
+ # Run backend (FastAPI) in background on :8000
8
+ # Note: Using 0.0.0.0 so it's reachable from the frontend container process
9
+ cd /app/metsa-backend
10
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 &
11
+ BACK_PID=$!
12
+ cd /app
13
+
14
+ echo "Backend started with PID ${BACK_PID}"
15
+
16
+ # We now serve the frontend statically from FastAPI (same port). Nothing else to run here.
17
+ wait ${BACK_PID}
18
+