From 99cab7be86c1af7939cfb8b6b429d555f32a6abe Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Sun, 12 Apr 2026 11:34:09 +0200 Subject: [PATCH] Igen --- linedance-api/.env.example | 19 +++- linedance-api/Dockerfile | 22 +++++ linedance-api/README.md | 106 ++++++-------------- linedance-api/app/core/config.py | 13 +++ linedance-api/app/core/mail.py | 103 +++++++++++++++++++ linedance-api/app/core/security.py | 36 +++---- linedance-api/app/models/__init__.py | 116 +++++++++++++--------- linedance-api/app/routers/auth.py | 142 ++++++++++++++++++++++++--- linedance-api/docker-compose.yml | 18 ++++ linedance-api/requirements.txt | 7 +- linedance-api/start.sh | 24 +++++ linedance-app/local/file_watcher.py | 99 +++---------------- linedance-app/ui/library_manager.py | 49 ++++----- linedance-app/ui/register_dialog.py | 131 ++++++++++++++++++++++++ linedance-app/ui/settings_dialog.py | 31 +++++- 15 files changed, 635 insertions(+), 281 deletions(-) create mode 100644 linedance-api/Dockerfile create mode 100644 linedance-api/app/core/mail.py create mode 100644 linedance-api/docker-compose.yml create mode 100755 linedance-api/start.sh create mode 100644 linedance-app/ui/register_dialog.py diff --git a/linedance-api/.env.example b/linedance-api/.env.example index 4bf3033c..5688884c 100644 --- a/linedance-api/.env.example +++ b/linedance-api/.env.example @@ -1,3 +1,18 @@ -DATABASE_URL=mysql+pymysql://bruger:kodeord@localhost:3306/linedance -SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng +# Database — din MySQL server +DATABASE_URL=mysql+pymysql://BRUGER:KODEORD@mysql.ckvist.lan:3306/linedance + +# JWT — generer med: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng-mindst-32-tegn ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# Mail — din SMTP server +MAIL_HOST=din-smtp-server +MAIL_PORT=587 +MAIL_FROM=noreply@din-domæne.dk +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_TLS=true + +# URL til verificerings-links i mails +# Skal være den adresse din Docker-container er tilgængelig på +BASE_URL=http://din-server-ip:8000 diff --git a/linedance-api/Dockerfile b/linedance-api/Dockerfile new file mode 100644 index 00000000..97d0cf99 --- /dev/null +++ b/linedance-api/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Installer system-dependencies +RUN apt-get update && apt-get install -y \ + default-libmysqlclient-dev \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Installer Python-pakker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Kopier kode +COPY . . + +# Vent på DB og start server +COPY start.sh . +RUN chmod +x start.sh +CMD ["./start.sh"] diff --git a/linedance-api/README.md b/linedance-api/README.md index 6711f83f..9b02baff 100644 --- a/linedance-api/README.md +++ b/linedance-api/README.md @@ -1,87 +1,39 @@ -# Linedance API +# LineDance API -FastAPI backend med MySQL, JWT auth og WebSocket live-opdateringer. +## Hurtig start med Docker -## Opsætning på VPS - -### 1. Klon og installer -```bash -git clone -cd linedance-api -python -m venv venv -source venv/bin/activate -pip install -r requirements.txt -``` - -### 2. Konfigurer miljøvariabler ```bash +# 1. Kopiér miljøfil cp .env.example .env + +# 2. Rediger .env — sæt stærke kodeord nano .env + +# 3. Start hele stacken +docker compose up -d + +# 4. Tjek at alt kører +docker compose ps +docker compose logs api ``` -Udfyld disse værdier: -``` -DATABASE_URL=mysql+pymysql://BRUGER:KODEORD@localhost:3306/linedance -SECRET_KEY= -ACCESS_TOKEN_EXPIRE_MINUTES=10080 -``` +## Tilgængelige services -### 3. Opret MySQL-database -```sql -CREATE DATABASE linedance CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER 'linedance'@'localhost' IDENTIFIED BY 'dit-kodeord'; -GRANT ALL PRIVILEGES ON linedance.* TO 'linedance'@'localhost'; -FLUSH PRIVILEGES; -``` +| Service | URL | Beskrivelse | +|----------|----------------------------|--------------------------| +| API | http://localhost:8000 | FastAPI | +| Docs | http://localhost:8000/docs | Swagger UI | +| Adminer | http://localhost:8080 | Database admin | +| MailHog | http://localhost:8025 | Test-mails | -### 4. Start API (udvikling) -```bash -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` +## Adminer login +- Server: `db` +- Bruger: `linedance` +- Kodeord: (fra .env MYSQL_PASSWORD) +- Database: `linedance` -### 5. Start API (produktion med systemd) -Opret `/etc/systemd/system/linedance.service`: -```ini -[Unit] -Description=Linedance API -After=network.target - -[Service] -User=www-data -WorkingDirectory=/var/www/linedance-api -Environment="PATH=/var/www/linedance-api/venv/bin" -ExecStart=/var/www/linedance-api/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 -Restart=always - -[Install] -WantedBy=multi-user.target -``` - -```bash -systemctl enable linedance -systemctl start linedance -``` - -## API-oversigt - -| Metode | Sti | Beskrivelse | -|--------|-----|-------------| -| POST | `/auth/register` | Opret bruger | -| POST | `/auth/login` | Log ind, få token | -| GET | `/projects/` | Mine projekter | -| POST | `/projects/` | Opret projekt | -| PATCH | `/projects/{id}` | Rediger projekt | -| POST | `/projects/{id}/invite` | Inviter bruger | -| GET | `/projects/invitations/pending` | Afventende invitationer | -| POST | `/projects/invitations/{id}/accept` | Accepter invitation | -| GET | `/projects/{id}/songs` | Danseliste | -| POST | `/projects/{id}/songs` | Tilføj sang | -| PATCH | `/projects/{id}/songs/{ps_id}/status` | Opdater status (playing/played/skipped) | -| GET | `/songs/` | Mine sange | -| POST | `/songs/` | Opret sang | -| POST | `/songs/{id}/dances` | Tilføj dans til sang | -| POST | `/songs/{id}/dances/{did}/alternatives` | Tilføj alternativ-dans | -| WS | `/ws/{project_id}` | Live opdateringer | - -## Interaktiv dokumentation -Åbn `http://din-server:8000/docs` i browseren. +## Produktion +- Skift `MAIL_HOST` til rigtig SMTP-server +- Sæt `BASE_URL` til dit domæne +- Brug `SECRET_KEY` med mindst 32 tilfældige tegn +- Fjern `adminer` og `mailhog` fra docker-compose diff --git a/linedance-api/app/core/config.py b/linedance-api/app/core/config.py index d6272904..c664973c 100644 --- a/linedance-api/app/core/config.py +++ b/linedance-api/app/core/config.py @@ -1,11 +1,24 @@ from pydantic_settings import BaseSettings + class Settings(BaseSettings): DATABASE_URL: str SECRET_KEY: str ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage + # Mail + MAIL_HOST: str = "mailhog" + MAIL_PORT: int = 1025 + MAIL_FROM: str = "noreply@linedance.local" + MAIL_USERNAME: str = "" + MAIL_PASSWORD: str = "" + MAIL_TLS: bool = False + + # Base URL til verificerings-links + BASE_URL: str = "http://localhost:8000" + class Config: env_file = ".env" + settings = Settings() diff --git a/linedance-api/app/core/mail.py b/linedance-api/app/core/mail.py new file mode 100644 index 00000000..1e16fbf8 --- /dev/null +++ b/linedance-api/app/core/mail.py @@ -0,0 +1,103 @@ +""" +mail.py — Asynkron mail-sending via aiosmtplib. +I udvikling bruges MailHog som SMTP-server. +""" +import asyncio +import secrets +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiosmtplib +from app.core.config import settings + + +def generate_verify_token() -> str: + return secrets.token_urlsafe(32) + + +async def send_verification_email(email: str, username: str, token: str): + verify_url = f"{settings.BASE_URL}/auth/verify/{token}" + + msg = MIMEMultipart("alternative") + msg["Subject"] = "Bekræft din LineDance-konto" + msg["From"] = settings.MAIL_FROM + msg["To"] = email + + text = f"""Hej {username}, + +Tak for at oprette en konto på LineDance Player. + +Klik på linket nedenfor for at bekræfte din e-mailadresse: +{verify_url} + +Linket udløber ikke — men kontoen er ikke aktiv før du har bekræftet. + +Hilsen +LineDance Player +""" + + html = f""" +

Velkommen til LineDance Player, {username}!

+

Klik på knappen nedenfor for at bekræfte din e-mailadresse:

+

+ + Bekræft e-mail + +

+

Eller kopier dette link:
+{verify_url}

+

Linket udløber ikke.

+""" + + msg.attach(MIMEText(text, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + + try: + await aiosmtplib.send( + msg, + hostname=settings.MAIL_HOST, + port=settings.MAIL_PORT, + username=settings.MAIL_USERNAME or None, + password=settings.MAIL_PASSWORD or None, + use_tls=settings.MAIL_TLS, + ) + except Exception as e: + # Log fejl men lad registrering gennemføre + print(f"Mail-fejl: {e}") + + +async def send_share_invitation(email: str, owner_name: str, + playlist_name: str, permission: str, + accept_url: str): + perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(permission, "se") + + msg = MIMEMultipart("alternative") + msg["Subject"] = f"{owner_name} har delt en danseliste med dig" + msg["From"] = settings.MAIL_FROM + msg["To"] = email + + html = f""" +

Du er inviteret!

+

{owner_name} har delt danselisten {playlist_name} med dig.

+

Du har fået adgang til at {perm_text} listen.

+

+ + Se danseliste + +

+""" + + msg.attach(MIMEText(html, "html", "utf-8")) + try: + await aiosmtplib.send( + msg, + hostname=settings.MAIL_HOST, + port=settings.MAIL_PORT, + use_tls=settings.MAIL_TLS, + ) + except Exception as e: + print(f"Mail-fejl (share): {e}") diff --git a/linedance-api/app/core/security.py b/linedance-api/app/core/security.py index 2f51eaea..291b2ef5 100644 --- a/linedance-api/app/core/security.py +++ b/linedance-api/app/core/security.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta, timezone +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session from app.core.config import settings from app.core.database import get_db @@ -10,44 +10,32 @@ from app.core.database import get_db pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") -ALGORITHM = "HS256" - def hash_password(password: str) -> str: return pwd_context.hash(password) - def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) - def create_access_token(data: dict) -> str: expire = datetime.now(timezone.utc) + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) - return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM) - + return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm="HS256") def get_current_user( token: str = Depends(oauth2_scheme), - db: Session = Depends(get_db), + db: Session = Depends(get_db) ): - from app.models.user import User - - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Kunne ikke validere token", - headers={"WWW-Authenticate": "Bearer"}, - ) + from app.models import User try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) - user_id: str = payload.get("sub") - if user_id is None: - raise credentials_exception + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(401, "Ugyldig token") except JWTError: - raise credentials_exception - + raise HTTPException(401, "Ugyldig token") user = db.query(User).filter(User.id == user_id).first() - if user is None: - raise credentials_exception + if not user: + raise HTTPException(401, "Bruger ikke fundet") return user diff --git a/linedance-api/app/models/__init__.py b/linedance-api/app/models/__init__.py index 964a5319..b0abed6f 100644 --- a/linedance-api/app/models/__init__.py +++ b/linedance-api/app/models/__init__.py @@ -17,16 +17,20 @@ def now_utc() -> datetime: class User(Base): __tablename__ = "users" - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + full_name: Mapped[str] = mapped_column(String(128), default="") + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False) + verify_token: Mapped[str|None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) - projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner") - memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user") - songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner") + projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner") + memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user") + songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner") alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user") + playlist_shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", foreign_keys="PlaylistShare.shared_with_id", back_populates="shared_with") # ── Song ────────────────────────────────────────────────────────────────────── @@ -42,12 +46,12 @@ class Song(Base): bpm: Mapped[int] = mapped_column(Integer, default=0) duration_sec: Mapped[int] = mapped_column(Integer, default=0) file_format: Mapped[str] = mapped_column(String(8), default="") - mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) # MusicBrainz ID - acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True) # AcoustID fingerprint + mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) + acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True) synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) - owner: Mapped["User"] = relationship("User", back_populates="songs") - project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song") + owner: Mapped["User"] = relationship("User", back_populates="songs") + project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song") # ── Dans-entitet ────────────────────────────────────────────────────────────── @@ -66,12 +70,16 @@ class Dance(Base): __tablename__ = "dances" __table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),) - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(128), nullable=False) - level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True) - use_count: Mapped[int] = mapped_column(Integer, default=1) - source: Mapped[str] = mapped_column(String(16), default="local") # local | community - synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True) + choreographer: Mapped[str] = mapped_column(String(128), default="") + video_url: Mapped[str] = mapped_column(String(512), default="") + stepsheet_url: Mapped[str] = mapped_column(String(512), default="") + notes: Mapped[str] = mapped_column(Text, default="") + use_count: Mapped[int] = mapped_column(Integer, default=1) + source: Mapped[str] = mapped_column(String(16), default="local") + synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True) level: Mapped["DanceLevel|None"] = relationship("DanceLevel") @@ -85,12 +93,14 @@ class Project(Base): owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False) description: Mapped[str] = mapped_column(Text, default="") - is_public: Mapped[bool] = mapped_column(Boolean, default=False) + visibility: Mapped[str] = mapped_column(String(16), default="private") # private|shared|public updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) owner: Mapped["User"] = relationship("User", back_populates="projects") members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan") project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="project", order_by="ProjectSong.position", cascade="all, delete-orphan") + shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", back_populates="project", cascade="all, delete-orphan") class ProjectMember(Base): @@ -99,8 +109,8 @@ class ProjectMember(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False) user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) - role: Mapped[str] = mapped_column(String(16), default="viewer") # owner | editor | viewer - status: Mapped[str] = mapped_column(String(16), default="pending") # pending | accepted + role: Mapped[str] = mapped_column(String(16), default="viewer") # owner|editor|viewer + status: Mapped[str] = mapped_column(String(16), default="pending") # pending|accepted invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) project: Mapped["Project"] = relationship("Project", back_populates="members") @@ -110,43 +120,57 @@ class ProjectMember(Base): class ProjectSong(Base): __tablename__ = "project_songs" - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False) - song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False) - position: Mapped[int] = mapped_column(Integer, nullable=False) - status: Mapped[str] = mapped_column(String(16), default="pending") # pending|played|skipped + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False) + song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False) + position: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="pending") + is_workshop: Mapped[bool] = mapped_column(Boolean, default=False) + dance_override: Mapped[str] = mapped_column(String(128), default="") project: Mapped["Project"] = relationship("Project", back_populates="project_songs") song: Mapped["Song"] = relationship("Song", back_populates="project_songs") +class PlaylistShare(Base): + """Deling af en playlist med specifikke brugere.""" + __tablename__ = "playlist_shares" + __table_args__ = (UniqueConstraint("project_id", "shared_with_id", name="uq_share"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False) + shared_with_id: Mapped[str|None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True) + invited_email: Mapped[str] = mapped_column(String(255), default="") # til ikke-registrerede + permission: Mapped[str] = mapped_column(String(16), default="view") # view|copy|edit + accepted_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + + project: Mapped["Project"] = relationship("Project", back_populates="shares") + shared_with: Mapped["User|None"] = relationship("User", foreign_keys=[shared_with_id], back_populates="playlist_shares") + + # ── Community dans-tags ─────────────────────────────────────────────────────── class CommunityDance(Base): - """Fællesskabets dans-tags på sange — identificeret ved mbid eller titel+artist.""" + """Fællesskabets dans-tags på sange.""" __tablename__ = "community_dances" - __table_args__ = (UniqueConstraint("song_mbid", "dance_id", name="uq_comm_dance"),) + __table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "dance_id", name="uq_comm_dance"),) - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) - song_title: Mapped[str] = mapped_column(String(255), default="") - song_artist: Mapped[str] = mapped_column(String(255), default="") - dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False) - submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) + song_title: Mapped[str] = mapped_column(String(255), default="") + song_artist: Mapped[str] = mapped_column(String(255), default="") + dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False) + submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) dance: Mapped["Dance"] = relationship("Dance") -# ── Community alternativ-dans + rating ──────────────────────────────────────── - class CommunityDanceAlt(Base): - """Fællesskabets alternativ-danse til en sang — uafhængigt af sangens hoveddanse.""" + """Fællesskabets alternativ-danse til en sang med ratings.""" __tablename__ = "community_dance_alts" - __table_args__ = ( - UniqueConstraint("song_mbid", "song_title", "song_artist", - "alt_dance_id", name="uq_comm_alt"), - ) + __table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "alt_dance_id", name="uq_comm_alt"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) @@ -159,16 +183,14 @@ class CommunityDanceAlt(Base): rating_count: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc) - alt_dance: Mapped["Dance"] = relationship("Dance", foreign_keys=[alt_dance_id]) + alt_dance: Mapped["Dance"] = relationship("Dance") ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="alternative", cascade="all, delete-orphan") class DanceAltRating(Base): - """En brugers 1-5 stjerne rating — knyttet til sang + dans + alternativ + bruger.""" + """1-5 stjerne rating af en alternativ-dans.""" __tablename__ = "dance_alt_ratings" - __table_args__ = ( - UniqueConstraint("alternative_id", "user_id", name="uq_rating"), - ) + __table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False) diff --git a/linedance-api/app/routers/auth.py b/linedance-api/app/routers/auth.py index 0e16aaac..22d1c0a3 100644 --- a/linedance-api/app/routers/auth.py +++ b/linedance-api/app/routers/auth.py @@ -1,39 +1,151 @@ -from fastapi import APIRouter, Depends, HTTPException, status +""" +auth.py — Register, verify, login, profil. +""" +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from fastapi.security import OAuth2PasswordRequestForm +from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr + from app.core.database import get_db -from app.core.security import hash_password, verify_password, create_access_token +from app.core.security import hash_password, verify_password, create_access_token, get_current_user +from app.core.mail import generate_verify_token, send_verification_email from app.models import User -from app.schemas import UserCreate, UserOut, Token router = APIRouter(prefix="/auth", tags=["auth"]) -@router.post("/register", response_model=UserOut, status_code=201) -def register(data: UserCreate, db: Session = Depends(get_db)): +# ── Schemas ─────────────────────────────────────────────────────────────────── + +class UserCreate(BaseModel): + username: str + email: EmailStr + full_name: str = "" + password: str + +class UserOut(BaseModel): + id: str + username: str + email: str + full_name: str + is_verified: bool + created_at: datetime + + class Config: + from_attributes = True + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserOut + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.post("/register", response_model=dict, status_code=201) +async def register( + data: UserCreate, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + # Tjek om brugernavn eller email allerede er i brug if db.query(User).filter(User.username == data.username).first(): raise HTTPException(400, "Brugernavnet er allerede i brug") if db.query(User).filter(User.email == data.email).first(): - raise HTTPException(400, "E-mailen er allerede i brug") + raise HTTPException(400, "E-mailadressen er allerede registreret") + token = generate_verify_token() user = User( username=data.username, email=data.email, + full_name=data.full_name, password_hash=hash_password(data.password), + is_verified=False, + verify_token=token, ) db.add(user) db.commit() - db.refresh(user) - return user + + # Send verificerings-mail i baggrunden + background_tasks.add_task( + send_verification_email, data.email, data.username, token + ) + + return { + "message": f"Konto oprettet. Tjek din e-mail ({data.email}) for at bekræfte.", + "email": data.email, + } + + +@router.get("/verify/{token}", response_class=HTMLResponse) +def verify_email(token: str, db: Session = Depends(get_db)): + user = db.query(User).filter(User.verify_token == token).first() + if not user: + return HTMLResponse(""" + +

❌ Ugyldigt eller udløbet link

+

Prøv at registrere dig igen.

+ + """, status_code=400) + + user.is_verified = True + user.verify_token = None + db.commit() + + return HTMLResponse(""" + +

✓ E-mail bekræftet!

+

Din konto er nu aktiv. Du kan logge ind i LineDance Player.

+

Du kan lukke dette vindue.

+ + """) @router.post("/login", response_model=Token) -def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): - user = db.query(User).filter(User.username == form.username).first() +def login( + form: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) +): + user = db.query(User).filter( + (User.username == form.username) | (User.email == form.username) + ).first() + if not user or not verify_password(form.password, user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Forkert brugernavn eller kodeord", - ) + raise HTTPException(401, "Forkert brugernavn eller kodeord") + + if not user.is_verified: + raise HTTPException(403, "E-mailadressen er ikke bekræftet endnu. Tjek din indbakke.") + token = create_access_token({"sub": user.id}) - return {"access_token": token} + return Token( + access_token=token, + user=UserOut.model_validate(user) + ) + + +@router.get("/me", response_model=UserOut) +def me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.post("/resend-verification") +async def resend_verification( + email: str, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + user = db.query(User).filter(User.email == email).first() + if not user or user.is_verified: + # Svar altid OK — afslør ikke om email eksisterer + return {"message": "Hvis e-mailen er registreret, sendes et nyt link."} + + token = generate_verify_token() + user.verify_token = token + db.commit() + + background_tasks.add_task( + send_verification_email, user.email, user.username, token + ) + return {"message": "Nyt verificerings-link er sendt."} diff --git a/linedance-api/docker-compose.yml b/linedance-api/docker-compose.yml new file mode 100644 index 00000000..e67b75cb --- /dev/null +++ b/linedance-api/docker-compose.yml @@ -0,0 +1,18 @@ +services: + + api: + build: . + restart: always + ports: + - "8000:8000" + env_file: + - .env + volumes: + - .:/app + + adminer: + image: adminer + restart: always + ports: + - "8080:8080" + diff --git a/linedance-api/requirements.txt b/linedance-api/requirements.txt index 3312fe95..70122fc4 100644 --- a/linedance-api/requirements.txt +++ b/linedance-api/requirements.txt @@ -9,7 +9,6 @@ pydantic[email]>=2.0.0 pydantic-settings>=2.0.0 python-dotenv>=1.0.0 python-multipart>=0.0.9 - -# Lokalt data-lag -mutagen>=1.47.0 -watchdog>=4.0.0 +aiosmtplib>=3.0.0 +jinja2>=3.1.0 +cryptography>=42.0.0 diff --git a/linedance-api/start.sh b/linedance-api/start.sh new file mode 100755 index 00000000..5bf1d3ed --- /dev/null +++ b/linedance-api/start.sh @@ -0,0 +1,24 @@ +#!/bin/bash +echo "Forbinder til database..." +for i in $(seq 1 30); do + python -c " +import pymysql, os, re +url = os.environ.get('DATABASE_URL', '') +m = re.match(r'mysql\+pymysql://([^:]+):([^@]+)@([^:/]+):?(\d+)?/(\w+)', url) +if not m: + exit(1) +user, password, host, port, db = m.groups() +port = int(port or 3306) +try: + conn = pymysql.connect(host=host, port=port, user=user, password=password, database=db) + conn.close() + print('Database OK') + exit(0) +except Exception as e: + print(f'Venter på database... ({e})') + exit(1) +" && break + sleep 2 +done + +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/linedance-app/local/file_watcher.py b/linedance-app/local/file_watcher.py index efc9e8f8..a0022e16 100644 --- a/linedance-app/local/file_watcher.py +++ b/linedance-app/local/file_watcher.py @@ -148,97 +148,22 @@ class LibraryWatcher: self._running = False def add_library(self, path: str) -> int: - """Tilføj et nyt bibliotek — scanner i baggrundstråd med egen DB-forbindelse.""" + """Tilføj et nyt bibliotek — alt kører i baggrundstråd.""" library_id = add_library(path) - if self._observer and self._running: - handler = MusicLibraryHandler(library_id, self.on_change) - self._observer.schedule(handler, path, recursive=True) + def _bg(lib_id, lib_path): + # Registrer watchdog — kan blokere lidt på Windows med mange filer + if self._observer and self._running: + try: + handler = MusicLibraryHandler(lib_id, self.on_change) + self._observer.schedule(handler, lib_path, recursive=True) + except Exception as e: + logger.error(f"Watchdog schedule fejl: {e}") - # Scan i baggrundstråd med daemon=True så den ikke blokerer programlukning - def _scan_in_background(lib_id, lib_path): - try: - import sqlite3 - from local.local_db import DB_PATH, is_supported, get_file_modified_at - from local.tag_reader import read_tags - import os + # Scan + self._full_scan_library(lib_id, lib_path) - # Åbn egen forbindelse — deler ikke med GUI-tråden - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - - base = Path(lib_path) - if not self._path_accessible(base): - conn.close() - return - - known = { - row["local_path"]: row["file_modified_at"] - for row in conn.execute( - "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", - (lib_id,) - ).fetchall() - } - - processed = 0 - for dirpath, _, filenames in os.walk(str(base), followlinks=False): - for filename in filenames: - file_path = Path(dirpath) / filename - if not is_supported(file_path): - continue - path_str = str(file_path) - disk_modified = get_file_modified_at(file_path) - if path_str not in known or known[path_str] != disk_modified: - try: - tags = read_tags(file_path) - tags["library_id"] = lib_id - # Upsert via direkte SQL på denne forbindelse - import uuid, json - existing = conn.execute( - "SELECT id FROM songs WHERE local_path=?", - (path_str,) - ).fetchone() - extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False) - if existing: - conn.execute(""" - UPDATE songs SET library_id=?, title=?, artist=?, - album=?, bpm=?, duration_sec=?, file_format=?, - file_modified_at=?, file_missing=0, extra_tags=? - WHERE id=? - """, (lib_id, tags.get("title",""), tags.get("artist",""), - tags.get("album",""), tags.get("bpm",0), - tags.get("duration_sec",0), tags.get("file_format",""), - disk_modified, extra, existing["id"])) - song_id = existing["id"] - else: - song_id = str(uuid.uuid4()) - conn.execute(""" - INSERT INTO songs (id, library_id, local_path, title, - artist, album, bpm, duration_sec, file_format, - file_modified_at, extra_tags) - VALUES (?,?,?,?,?,?,?,?,?,?,?) - """, (song_id, lib_id, path_str, tags.get("title",""), - tags.get("artist",""), tags.get("album",""), - tags.get("bpm",0), tags.get("duration_sec",0), - tags.get("file_format",""), disk_modified, extra)) - conn.commit() - processed += 1 - if self.on_change: - self.on_change("upserted", path_str, song_id) - except Exception as e: - logger.error(f"Scan fejl: {file_path}: {e}") - - conn.execute( - "UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?", - (lib_id,) - ) - conn.commit() - conn.close() - logger.info(f"Bibliotek scannet: {lib_path} — {processed} filer") - except Exception as e: - logger.error(f"Baggrunds-scan fejl: {e}") - - t = threading.Thread(target=_scan_in_background, args=(library_id, path), daemon=True) + t = threading.Thread(target=_bg, args=(library_id, path), daemon=True) t.start() return library_id diff --git a/linedance-app/ui/library_manager.py b/linedance-app/ui/library_manager.py index e8bcf037..8b4fc86f 100644 --- a/linedance-app/ui/library_manager.py +++ b/linedance-app/ui/library_manager.py @@ -107,19 +107,35 @@ class LibraryManagerDialog(QDialog): layout.addLayout(btn_row) def _load(self): - # Ryd eksisterende widgets while self._libs_layout.count(): item = self._libs_layout.takeAt(0) if item.widget(): item.widget().deleteLater() try: - from local.local_db import get_libraries, get_db + import sqlite3 + from local.local_db import DB_PATH, get_libraries libs = get_libraries(active_only=True) for lib in libs: - self._libs_layout.addWidget(self._make_lib_row(lib)) + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + count = conn.execute( + "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0", + (lib["id"],) + ).fetchone()[0] + missing_bpm = conn.execute( + "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0 " + "AND (bpm IS NULL OR bpm=0)", + (lib["id"],) + ).fetchone()[0] + conn.close() + lib_dict = dict(lib) + lib_dict["_count"] = count + lib_dict["_missing_bpm"] = missing_bpm + self._libs_layout.addWidget(self._make_lib_row(lib_dict)) except Exception as e: - pass + lbl = QLabel(f"Fejl: {e}") + self._libs_layout.addWidget(lbl) def _make_lib_row(self, lib: dict) -> QFrame: from pathlib import Path @@ -137,21 +153,8 @@ class LibraryManagerDialog(QDialog): last_scan = lib.get("last_full_scan") or "aldrig" if isinstance(last_scan, str) and len(last_scan) > 10: last_scan = last_scan[:10] - try: - from local.local_db import get_db - with get_db() as conn: - total = conn.execute( - "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0", - (lib_id,) - ).fetchone()[0] - missing_bpm = conn.execute( - "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0 " - "AND (bpm IS NULL OR bpm=0)", - (lib_id,) - ).fetchone()[0] - except Exception: - total = 0 - missing_bpm = 0 + total = lib.get("_count", 0) + missing_bpm = lib.get("_missing_bpm", 0) lbl_path = QLabel(("⚠ " if not exists else "") + path) lbl_path.setObjectName("track_title" if exists else "result_count") @@ -175,13 +178,13 @@ class LibraryManagerDialog(QDialog): btn_row.setSpacing(6) btn_scan = QPushButton("⟳ Fil-scan") - btn_scan.setFixedHeight(24) + btn_scan.setFixedHeight(30) btn_scan.setToolTip("Scan for nye og ændrede filer") btn_scan.clicked.connect(lambda _, lid=lib_id, p=path: self._scan_files(lid, p)) btn_row.addWidget(btn_scan) btn_bpm = QPushButton(f"♩ BPM manglende ({missing_bpm})") - btn_bpm.setFixedHeight(24) + btn_bpm.setFixedHeight(30) btn_bpm.setToolTip("Analysér BPM på sange der mangler det") btn_bpm.setEnabled(missing_bpm > 0) btn_bpm.clicked.connect( @@ -190,7 +193,7 @@ class LibraryManagerDialog(QDialog): btn_row.addWidget(btn_bpm) btn_bpm_all = QPushButton("♩ BPM alle") - btn_bpm_all.setFixedHeight(24) + btn_bpm_all.setFixedHeight(30) btn_bpm_all.setToolTip("Genanalysér BPM på alle sange (overskriver eksisterende)") btn_bpm_all.clicked.connect( lambda _, lid=lib_id, b=btn_bpm_all, s=lbl_status: self._start_bpm(lid, True, b, s) @@ -200,7 +203,7 @@ class LibraryManagerDialog(QDialog): btn_row.addStretch() btn_remove = QPushButton("✕ Fjern") - btn_remove.setFixedHeight(24) + btn_remove.setFixedHeight(30) btn_remove.clicked.connect(lambda _, l=lib: self._remove_library(l)) btn_row.addWidget(btn_remove) diff --git a/linedance-app/ui/register_dialog.py b/linedance-app/ui/register_dialog.py new file mode 100644 index 00000000..5dceb4b6 --- /dev/null +++ b/linedance-app/ui/register_dialog.py @@ -0,0 +1,131 @@ +""" +register_dialog.py — Opret ny konto på LineDance API. +""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QFormLayout, QLabel, QLineEdit, + QPushButton, QGroupBox, QMessageBox, +) +from PyQt6.QtCore import Qt + + +class RegisterDialog(QDialog): + def __init__(self, server_url: str = "http://localhost:8000", parent=None): + super().__init__(parent) + self._server_url = server_url.rstrip("/") + self.setWindowTitle("Opret konto") + self.setMinimumWidth(400) + self._build_ui() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) + + lbl = QLabel(f"Opret konto på:\n{self._server_url}") + lbl.setObjectName("result_count") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(lbl) + + grp = QGroupBox("Kontooplysninger") + form = QFormLayout(grp) + + self._name = QLineEdit() + self._name.setPlaceholderText("Dit fulde navn") + form.addRow("Navn:", self._name) + + self._username = QLineEdit() + self._username.setPlaceholderText("brugernavn (ingen mellemrum)") + form.addRow("Brugernavn:", self._username) + + self._email = QLineEdit() + self._email.setPlaceholderText("din@email.dk") + form.addRow("E-mail:", self._email) + + self._password = QLineEdit() + self._password.setEchoMode(QLineEdit.EchoMode.Password) + self._password.setPlaceholderText("mindst 8 tegn") + form.addRow("Kodeord:", self._password) + + self._password2 = QLineEdit() + self._password2.setEchoMode(QLineEdit.EchoMode.Password) + self._password2.setPlaceholderText("gentag kodeord") + form.addRow("Gentag kodeord:", self._password2) + + layout.addWidget(grp) + + self._status = QLabel("") + self._status.setObjectName("result_count") + self._status.setWordWrap(True) + self._status.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self._status) + + btn_row = QVBoxLayout() + self._btn_register = QPushButton("✚ Opret konto") + self._btn_register.setObjectName("btn_play") + self._btn_register.clicked.connect(self._register) + btn_row.addWidget(self._btn_register) + + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + layout.addLayout(btn_row) + + def _register(self): + name = self._name.text().strip() + username = self._username.text().strip() + email = self._email.text().strip() + password = self._password.text() + password2 = self._password2.text() + + # Validering + if not all([username, email, password]): + self._status.setText("⚠ Udfyld alle felter.") + return + if " " in username: + self._status.setText("⚠ Brugernavnet må ikke indeholde mellemrum.") + return + if "@" not in email: + self._status.setText("⚠ Ugyldig e-mailadresse.") + return + if len(password) < 8: + self._status.setText("⚠ Kodeordet skal være mindst 8 tegn.") + return + if password != password2: + self._status.setText("⚠ Kodeordene er ikke ens.") + return + + self._btn_register.setEnabled(False) + self._status.setText("Opretter konto...") + + try: + import urllib.request, json + data = json.dumps({ + "username": username, + "email": email, + "full_name": name, + "password": password, + }).encode("utf-8") + req = urllib.request.Request( + f"{self._server_url}/auth/register", + data=data, + headers={"Content-Type": "application/json"}, + method="POST" + ) + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + + self._status.setText( + f"✓ Konto oprettet!\n\n" + f"Tjek din e-mail ({email}) for at bekræfte kontoen.\n" + f"Herefter kan du logge ind." + ) + self._btn_register.setEnabled(False) + + except urllib.error.HTTPError as e: + body = json.loads(e.read()) + msg = body.get("detail", str(e)) + self._status.setText(f"⚠ {msg}") + self._btn_register.setEnabled(True) + except Exception as e: + self._status.setText(f"⚠ Kunne ikke forbinde til server:\n{e}") + self._btn_register.setEnabled(True) diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py index d09a5049..20360a1f 100644 --- a/linedance-app/ui/settings_dialog.py +++ b/linedance-app/ui/settings_dialog.py @@ -20,6 +20,7 @@ SETTINGS_KEY_MAIL_PATH = "mail/custom_path" SETTINGS_KEY_AUTO_LOGIN = "online/auto_login" SETTINGS_KEY_USERNAME = "online/username" SETTINGS_KEY_PASSWORD = "online/password" +SETTINGS_KEY_SERVER_URL = "online/server_url" SETTINGS_KEY_LANGUAGE = "appearance/language" SETTINGS_KEY_BETWEEN_SEC = "playback/between_seconds" SETTINGS_KEY_WORKSHOP_MIN = "playback/workshop_minutes" @@ -37,6 +38,7 @@ def load_settings() -> dict: "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool), "username": s.value(SETTINGS_KEY_USERNAME, ""), "password": s.value(SETTINGS_KEY_PASSWORD, ""), + "server_url": s.value(SETTINGS_KEY_SERVER_URL, "http://localhost:8000"), "language": s.value(SETTINGS_KEY_LANGUAGE, "da"), "between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int), "workshop_minutes": s.value(SETTINGS_KEY_WORKSHOP_MIN, 10, type=int), @@ -54,6 +56,7 @@ def save_settings(values: dict): s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False)) s.setValue(SETTINGS_KEY_USERNAME, values.get("username", "")) s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", "")) + s.setValue(SETTINGS_KEY_SERVER_URL, values.get("server_url", "http://localhost:8000")) s.setValue(SETTINGS_KEY_LANGUAGE, values.get("language", "da")) s.setValue(SETTINGS_KEY_BETWEEN_SEC, values.get("between_seconds", 60)) s.setValue(SETTINGS_KEY_WORKSHOP_MIN,values.get("workshop_minutes", 10)) @@ -230,15 +233,31 @@ class SettingsDialog(QDialog): layout = QVBoxLayout(tab) layout.setSpacing(12) - grp = QGroupBox("Automatisk login ved opstart") + # Server URL + grp_server = QGroupBox("Server") + grp_server_layout = QFormLayout(grp_server) + self._server_url = QLineEdit() + self._server_url.setPlaceholderText("http://localhost:8000") + grp_server_layout.addRow("API-adresse:", self._server_url) + note_server = QLabel("Adressen på LineDance API-serveren.") + note_server.setObjectName("result_count") + grp_server_layout.addRow(note_server) + layout.addWidget(grp_server) + + # Login + grp = QGroupBox("Konto") grp_layout = QFormLayout(grp) + btn_register = QPushButton("✚ Opret ny konto...") + btn_register.clicked.connect(self._open_register) + grp_layout.addRow(btn_register) + self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter") self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed) grp_layout.addRow(self._chk_auto_login) self._user_input = QLineEdit() - self._user_input.setPlaceholderText("dit-brugernavn") + self._user_input.setPlaceholderText("brugernavn eller e-mail") grp_layout.addRow("Brugernavn:", self._user_input) self._pass_input = QLineEdit() @@ -257,6 +276,12 @@ class SettingsDialog(QDialog): layout.addStretch() return tab + def _open_register(self): + from ui.register_dialog import RegisterDialog + server_url = self._server_url.text().strip() or "http://localhost:8000" + dlg = RegisterDialog(server_url=server_url, parent=self) + dlg.exec() + def _build_language_tab(self) -> QWidget: tab = QWidget() layout = QVBoxLayout(tab) @@ -311,6 +336,7 @@ class SettingsDialog(QDialog): self._chk_auto_login.setChecked(auto) self._user_input.setText(v.get("username", "")) self._pass_input.setText(v.get("password", "")) + self._server_url.setText(v.get("server_url", "http://localhost:8000")) self._user_input.setEnabled(auto) self._pass_input.setEnabled(auto) @@ -328,6 +354,7 @@ class SettingsDialog(QDialog): "auto_login": self._chk_auto_login.isChecked(), "username": self._user_input.text().strip(), "password": self._pass_input.text(), + "server_url": self._server_url.text().strip() or "http://localhost:8000", "language": self._lang_combo.currentData(), } save_settings(values)