Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import relationship
from app.database import Base


class User(Base):
__tablename__ = "users"

id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, nullable=True)
phone = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active = Column(Boolean, default=True)
is_deleted = Column(Boolean, default=False)
deleted_at = Column(DateTime, nullable=True)

expenses = relationship("Expense", back_populates="user", cascade="all, delete-orphan")
bills = relationship("Bill", back_populates="user", cascade="all, delete-orphan")
reminders = relationship("Reminder", back_populates="user", cascade="all, delete-orphan")
categories = relationship("Category", back_populates="user", cascade="all, delete-orphan")
audit_logs = relationship("AuditLog", back_populates="user")


class AuditLog(Base):
__tablename__ = "audit_logs"

id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, nullable=True)
user_email = Column(String, nullable=True)
action = Column(String, nullable=False)
entity = Column(String, nullable=True)
entity_id = Column(Integer, nullable=True)
metadata = Column(String, nullable=True)
ip_address = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

from sqlalchemy import ForeignKey
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
user = relationship("User", back_populates="audit_logs")
136 changes: 136 additions & 0 deletions backend/app/routes/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import io
import json
import zipfile
from datetime import datetime
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.user import User
from app.services.audit_logger import log_action
from app.dependencies import get_current_user

router = APIRouter(prefix="/users", tags=["users"])


def _serialize_row(obj) -> dict:
result = {}
for col in obj.__table__.columns:
val = getattr(obj, col.name)
if isinstance(val, datetime):
val = val.isoformat()
result[col.name] = val
return result


@router.get("/me/export", summary="Export all personal data (GDPR)")
def export_user_data(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user_data = _serialize_row(current_user)
user_data.pop("hashed_password", None)

expenses = [
_serialize_row(e)
for e in getattr(current_user, "expenses", [])
]
bills = [
_serialize_row(b)
for b in getattr(current_user, "bills", [])
]
reminders = [
_serialize_row(r)
for r in getattr(current_user, "reminders", [])
]
categories = [
_serialize_row(c)
for c in getattr(current_user, "categories", [])
]

package = {
"exported_at": datetime.utcnow().isoformat(),
"user": user_data,
"expenses": expenses,
"bills": bills,
"reminders": reminders,
"categories": categories,
}

zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("personal_data.json", json.dumps(package, indent=2))
zip_buffer.seek(0)

log_action(
db=db,
action="PII_EXPORT",
user_id=current_user.id,
user_email=current_user.email,
entity="user",
entity_id=current_user.id,
metadata={"exported_at": datetime.utcnow().isoformat()},
ip_address=request.client.host if request.client else None,
)

filename = f"personal_data_{current_user.id}_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.zip"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)


@router.delete(
"/me",
status_code=status.HTTP_200_OK,
summary="Permanently delete account and all personal data (GDPR)",
)
def delete_user_account(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user_id = current_user.id
user_email = current_user.email
ip = request.client.host if request.client else None

for expense in list(getattr(current_user, "expenses", [])):
db.delete(expense)

for bill in list(getattr(current_user, "bills", [])):
db.delete(bill)

for reminder in list(getattr(current_user, "reminders", [])):
db.delete(reminder)

for category in list(getattr(current_user, "categories", [])):
db.delete(category)

db.delete(current_user)
db.flush()

log_action(
db=db,
action="PII_DELETE",
user_id=None,
user_email=user_email,
entity="user",
entity_id=user_id,
metadata={
"deleted_at": datetime.utcnow().isoformat(),
"deleted_user_id": user_id,
"deleted_user_email": user_email,
},
ip_address=ip,
)

db.commit()

return {
"detail": "Account and all associated personal data have been permanently deleted."
}
31 changes: 31 additions & 0 deletions backend/app/services/audit_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import json
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.user import AuditLog


def log_action(
db: Session,
action: str,
user_id: Optional[int] = None,
user_email: Optional[str] = None,
entity: Optional[str] = None,
entity_id: Optional[int] = None,
metadata: Optional[dict] = None,
ip_address: Optional[str] = None,
) -> AuditLog:
entry = AuditLog(
user_id=user_id,
user_email=user_email,
action=action,
entity=entity,
entity_id=entity_id,
metadata=json.dumps(metadata) if metadata else None,
ip_address=ip_address,
created_at=datetime.utcnow(),
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry