diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 80f64b2..d068ca6 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session +from app.core.demo import DEMO_LOCKED_DETAIL, is_demo_user from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db from app.core.security import get_password_hash from app.models.user import User @@ -160,6 +161,9 @@ def update_user( detail="User not found", ) + if is_demo_user(user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=DEMO_LOCKED_DETAIL) + # Check if new email conflicts with another user if user_data.email and user_data.email != user.email: existing = db.query(User).filter(User.email == user_data.email).first() @@ -232,6 +236,9 @@ def update_user_status( detail="User not found", ) + if is_demo_user(user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=DEMO_LOCKED_DETAIL) + setattr(user, "is_active", status_data.is_active) db.commit() @@ -259,6 +266,9 @@ def reset_user_password( detail="User not found", ) + if is_demo_user(user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=DEMO_LOCKED_DETAIL) + setattr(user, "hashed_password", get_password_hash(reset_data.new_password)) db.commit() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 94a6a2d..4995c62 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -47,6 +47,13 @@ class Settings(BaseSettings): # Frontend frontend_url: str = "http://localhost:5173" + # Demo account (public playground), enabled by default. The account + # cannot be modified through the API and its data is recreated by + # reset_demo.py (scheduled from entrypoint.sh, every 3 hours). + # Set DEMO_EMAIL="" in the environment to disable the feature. + demo_email: str = "demo@example.com" + demo_password: str = "demo1234" + # Google Calendar OAuth google_client_id: str = "" google_client_secret: str = "" diff --git a/backend/app/core/demo.py b/backend/app/core/demo.py new file mode 100644 index 0000000..5890f43 --- /dev/null +++ b/backend/app/core/demo.py @@ -0,0 +1,17 @@ +"""Helpers for the public demo account. + +The demo account is identified by email (settings.demo_email, env DEMO_EMAIL) +so no schema change is needed. Its credentials and profile are locked through +the API and its data is recreated daily by reset_demo.py. +""" +from app.core.config import settings +from app.models.user import User + +DEMO_LOCKED_DETAIL = "Contul demo nu poate fi modificat." + + +def is_demo_user(user: User | None) -> bool: + """Return True if the given user is the configured demo account.""" + if user is None or not settings.demo_email: + return False + return user.email == settings.demo_email diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 98df0e5..13d8fb6 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -13,5 +13,19 @@ if [ "${RUN_SEED}" = "1" ]; then python seed_db.py fi +# Public demo account (enabled by default, see settings.demo_email): +# (re)create the demo account and its sample data at boot, then reset it +# periodically in the background (default: every 3h; override with +# DEMO_RESET_INTERVAL, in seconds). Set DEMO_EMAIL="" to disable. +echo "[entrypoint] Resetting demo account..." +python reset_demo.py || echo "[entrypoint] WARNING: demo reset failed (continuing)" +( + while true; do + sleep "${DEMO_RESET_INTERVAL:-10800}" + echo "[entrypoint] Scheduled demo reset..." + python reset_demo.py || echo "[entrypoint] WARNING: demo reset failed" + done +) & + echo "[entrypoint] Starting application..." exec "$@" diff --git a/backend/reset_demo.py b/backend/reset_demo.py new file mode 100644 index 0000000..59817dd --- /dev/null +++ b/backend/reset_demo.py @@ -0,0 +1,228 @@ +"""Reset the public demo account and its data. + +Idempotent: wipes everything owned by the demo account (properties it +manages, their spaces/bookings/attachments, its notifications, templates, +audit logs, memberships), restores its credentials and recreates a fresh +demo property with sample spaces and bookings. + +Enabled only when DEMO_EMAIL is set (see settings.demo_email). Scheduled +from entrypoint.sh; can also be run manually: + + python reset_demo.py +""" +import os +from datetime import datetime, timedelta + +from app.core.config import settings +from app.core.security import get_password_hash +from app.db.session import Base, SessionLocal, engine +from app.models.attachment import Attachment +from app.models.booking import Booking +from app.models.booking_template import BookingTemplate +from app.models.audit_log import AuditLog +from app.models.google_calendar_token import GoogleCalendarToken +from app.models.notification import Notification +from app.models.organization_member import OrganizationMember +from app.models.property import Property +from app.models.property_access import PropertyAccess +from app.models.property_manager import PropertyManager +from app.models.property_settings import PropertySettings +from app.models.space import Space +from app.models.user import User + +DEMO_FULL_NAME = "Cont Demo" +DEMO_PROPERTY_NAME = "Proprietate Demo" + + +def _wipe_demo_data(db, demo: User) -> None: + """Delete everything created by/for the demo account.""" + # Properties the demo account manages (including any it created itself). + prop_ids = [ + pm.property_id + for pm in db.query(PropertyManager).filter(PropertyManager.user_id == demo.id) + ] + + space_ids = [] + if prop_ids: + space_ids = [ + s.id for s in db.query(Space.id).filter(Space.property_id.in_(prop_ids)) + ] + + # Bookings inside demo spaces + any booking the demo user made elsewhere + # (e.g. on public properties of the real instance). + booking_query = db.query(Booking) + if space_ids: + booking_query = booking_query.filter( + (Booking.space_id.in_(space_ids)) | (Booking.user_id == demo.id) + ) + else: + booking_query = booking_query.filter(Booking.user_id == demo.id) + booking_ids = [b.id for b in booking_query.with_entities(Booking.id)] + + # Attachments: remove uploaded files from disk, then rows. + att_filter = Attachment.uploaded_by == demo.id + if booking_ids: + att_filter = att_filter | Attachment.booking_id.in_(booking_ids) + attachments = db.query(Attachment).filter(att_filter).all() + for att in attachments: + try: + if att.filepath and os.path.exists(att.filepath): + os.remove(att.filepath) + except OSError: + pass + db.delete(att) + db.flush() + + # Notifications referencing demo bookings (any user) + all demo's own. + if booking_ids: + db.query(Notification).filter(Notification.booking_id.in_(booking_ids)).delete( + synchronize_session=False + ) + db.query(Notification).filter(Notification.user_id == demo.id).delete( + synchronize_session=False + ) + + if booking_ids: + db.query(Booking).filter(Booking.id.in_(booking_ids)).delete( + synchronize_session=False + ) + + db.query(BookingTemplate).filter(BookingTemplate.user_id == demo.id).delete( + synchronize_session=False + ) + db.query(GoogleCalendarToken).filter(GoogleCalendarToken.user_id == demo.id).delete( + synchronize_session=False + ) + db.query(AuditLog).filter(AuditLog.user_id == demo.id).delete( + synchronize_session=False + ) + db.query(OrganizationMember).filter(OrganizationMember.user_id == demo.id).delete( + synchronize_session=False + ) + db.query(PropertyAccess).filter(PropertyAccess.user_id == demo.id).delete( + synchronize_session=False + ) + + if space_ids: + db.query(Space).filter(Space.id.in_(space_ids)).delete(synchronize_session=False) + if prop_ids: + db.query(PropertySettings).filter( + PropertySettings.property_id.in_(prop_ids) + ).delete(synchronize_session=False) + db.query(PropertyAccess).filter(PropertyAccess.property_id.in_(prop_ids)).delete( + synchronize_session=False + ) + db.query(PropertyManager).filter( + PropertyManager.property_id.in_(prop_ids) + ).delete(synchronize_session=False) + db.query(Property).filter(Property.id.in_(prop_ids)).delete( + synchronize_session=False + ) + + +def _seed_demo_data(db, demo: User) -> None: + """Create a fresh demo property with spaces and sample bookings.""" + prop = Property( + name=DEMO_PROPERTY_NAME, + description="Mediu de test — datele se resetează automat la fiecare 3 ore.", + address="Str. Exemplu nr. 1", + is_public=False, # visible only to the demo account + is_active=True, + ) + db.add(prop) + db.flush() + + db.add(PropertyManager(property_id=prop.id, user_id=demo.id)) + db.add(PropertySettings(property_id=prop.id, require_approval=True)) + + sala = Space( + name="Sala Conferințe", + type="sala", + capacity=12, + description="Sală de conferințe cu videoproiector", + is_active=True, + property_id=prop.id, + ) + birou = Space( + name="Birou Flex", + type="birou", + capacity=4, + description="Birou flexibil, 4 locuri", + is_active=True, + property_id=prop.id, + ) + sala_mica = Space( + name="Sala Meeting", + type="sala", + capacity=6, + description="Sală mică pentru întâlniri rapide", + is_active=True, + property_id=prop.id, + ) + db.add_all([sala, birou, sala_mica]) + db.flush() + + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + samples = [ + # (space, day offset, start hour, hours, title, status) + (sala, 0, 10, 2, "Prezentare produs", "approved"), + (birou, 0, 14, 3, "Sesiune de lucru echipă", "approved"), + (sala_mica, 1, 9, 1, "Daily standup", "approved"), + (sala, 1, 13, 2, "Interviu candidat", "pending"), + (birou, 2, 10, 4, "Workshop planificare", "pending"), + ] + for space, day, hour, hours, title, status in samples: + start = today + timedelta(days=day, hours=hour) + db.add( + Booking( + user_id=demo.id, + space_id=space.id, + title=title, + description="Rezervare de exemplu (demo)", + start_datetime=start, + end_datetime=start + timedelta(hours=hours), + status=status, + approved_by=demo.id if status == "approved" else None, + ) + ) + + +def reset_demo() -> None: + if not settings.demo_email: + print("DEMO_EMAIL is not set - demo account disabled, nothing to do.") + return + + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + demo = db.query(User).filter(User.email == settings.demo_email).first() + + if demo: + _wipe_demo_data(db, demo) + else: + demo = User(email=settings.demo_email, full_name=DEMO_FULL_NAME, + hashed_password="", role="manager") + db.add(demo) + + # Force-restore credentials and profile every run. + demo.full_name = DEMO_FULL_NAME + demo.hashed_password = get_password_hash(settings.demo_password) + demo.role = "manager" + demo.organization = "Demo" + demo.is_active = True + db.flush() + + _seed_demo_data(db, demo) + + db.commit() + print(f"Demo account reset: {settings.demo_email} / property '{DEMO_PROPERTY_NAME}'") + except Exception: + db.rollback() + raise + finally: + db.close() + + +if __name__ == "__main__": + reset_demo() diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 808674c..8ad08a0 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -41,6 +41,11 @@
Don't have an account?
+ Cont demo:
+ Datele contului demo se resetează automat la fiecare 3 ore.
+