Igen
This commit is contained in:
@@ -1,3 +1,18 @@
|
|||||||
DATABASE_URL=mysql+pymysql://bruger:kodeord@localhost:3306/linedance
|
# Database — din MySQL server
|
||||||
SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng
|
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
|
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
|
||||||
|
|||||||
22
linedance-api/Dockerfile
Normal file
22
linedance-api/Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -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 <dit-repo>
|
|
||||||
cd linedance-api
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Konfigurer miljøvariabler
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. Kopiér miljøfil
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 2. Rediger .env — sæt stærke kodeord
|
||||||
nano .env
|
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:
|
## Tilgængelige services
|
||||||
```
|
|
||||||
DATABASE_URL=mysql+pymysql://BRUGER:KODEORD@localhost:3306/linedance
|
|
||||||
SECRET_KEY=<lang tilfældig streng — kør: openssl rand -hex 32>
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Opret MySQL-database
|
| Service | URL | Beskrivelse |
|
||||||
```sql
|
|----------|----------------------------|--------------------------|
|
||||||
CREATE DATABASE linedance CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
| API | http://localhost:8000 | FastAPI |
|
||||||
CREATE USER 'linedance'@'localhost' IDENTIFIED BY 'dit-kodeord';
|
| Docs | http://localhost:8000/docs | Swagger UI |
|
||||||
GRANT ALL PRIVILEGES ON linedance.* TO 'linedance'@'localhost';
|
| Adminer | http://localhost:8080 | Database admin |
|
||||||
FLUSH PRIVILEGES;
|
| MailHog | http://localhost:8025 | Test-mails |
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Start API (udvikling)
|
## Adminer login
|
||||||
```bash
|
- Server: `db`
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
- Bruger: `linedance`
|
||||||
```
|
- Kodeord: (fra .env MYSQL_PASSWORD)
|
||||||
|
- Database: `linedance`
|
||||||
|
|
||||||
### 5. Start API (produktion med systemd)
|
## Produktion
|
||||||
Opret `/etc/systemd/system/linedance.service`:
|
- Skift `MAIL_HOST` til rigtig SMTP-server
|
||||||
```ini
|
- Sæt `BASE_URL` til dit domæne
|
||||||
[Unit]
|
- Brug `SECRET_KEY` med mindst 32 tilfældige tegn
|
||||||
Description=Linedance API
|
- Fjern `adminer` og `mailhog` fra docker-compose
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
DATABASE_URL: str
|
DATABASE_URL: str
|
||||||
SECRET_KEY: str
|
SECRET_KEY: str
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
103
linedance-api/app/core/mail.py
Normal file
103
linedance-api/app/core/mail.py
Normal file
@@ -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"""<html><body>
|
||||||
|
<h2>Velkommen til LineDance Player, {username}!</h2>
|
||||||
|
<p>Klik på knappen nedenfor for at bekræfte din e-mailadresse:</p>
|
||||||
|
<p>
|
||||||
|
<a href="{verify_url}"
|
||||||
|
style="background:#e8a020;color:#111;padding:12px 24px;
|
||||||
|
border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||||
|
Bekræft e-mail
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>Eller kopier dette link:<br>
|
||||||
|
<a href="{verify_url}">{verify_url}</a></p>
|
||||||
|
<p>Linket udløber ikke.</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
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"""<html><body>
|
||||||
|
<h2>Du er inviteret!</h2>
|
||||||
|
<p>{owner_name} har delt danselisten <strong>{playlist_name}</strong> med dig.</p>
|
||||||
|
<p>Du har fået adgang til at <strong>{perm_text}</strong> listen.</p>
|
||||||
|
<p>
|
||||||
|
<a href="{accept_url}"
|
||||||
|
style="background:#e8a020;color:#111;padding:12px 24px;
|
||||||
|
border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||||
|
Se danseliste
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
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}")
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
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")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
return pwd_context.verify(plain, hashed)
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict) -> str:
|
def create_access_token(data: dict) -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
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(
|
def get_current_user(
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
from app.models.user import User
|
from app.models import User
|
||||||
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Kunne ikke validere token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
user_id: str = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
if user_id is None:
|
if not user_id:
|
||||||
raise credentials_exception
|
raise HTTPException(401, "Ugyldig token")
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise HTTPException(401, "Ugyldig token")
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
if user is None:
|
if not user:
|
||||||
raise credentials_exception
|
raise HTTPException(401, "Bruger ikke fundet")
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -17,16 +17,20 @@ def now_utc() -> datetime:
|
|||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
email: Mapped[str] = mapped_column(String(255), 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)
|
full_name: Mapped[str] = mapped_column(String(128), default="")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
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")
|
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
||||||
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
||||||
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
|
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
|
||||||
alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user")
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── Song ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -42,12 +46,12 @@ class Song(Base):
|
|||||||
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
duration_sec: 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="")
|
file_format: Mapped[str] = mapped_column(String(8), default="")
|
||||||
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) # MusicBrainz ID
|
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||||
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True) # AcoustID fingerprint
|
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True)
|
||||||
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
owner: Mapped["User"] = relationship("User", back_populates="songs")
|
owner: Mapped["User"] = relationship("User", back_populates="songs")
|
||||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
||||||
|
|
||||||
|
|
||||||
# ── Dans-entitet ──────────────────────────────────────────────────────────────
|
# ── Dans-entitet ──────────────────────────────────────────────────────────────
|
||||||
@@ -66,12 +70,16 @@ class Dance(Base):
|
|||||||
__tablename__ = "dances"
|
__tablename__ = "dances"
|
||||||
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
|
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True)
|
level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True)
|
||||||
use_count: Mapped[int] = mapped_column(Integer, default=1)
|
choreographer: Mapped[str] = mapped_column(String(128), default="")
|
||||||
source: Mapped[str] = mapped_column(String(16), default="local") # local | community
|
video_url: Mapped[str] = mapped_column(String(512), default="")
|
||||||
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
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")
|
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)
|
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
description: Mapped[str] = mapped_column(Text, default="")
|
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)
|
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")
|
owner: Mapped["User"] = relationship("User", back_populates="projects")
|
||||||
members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
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")
|
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):
|
class ProjectMember(Base):
|
||||||
@@ -99,8 +109,8 @@ class ProjectMember(Base):
|
|||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
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)
|
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)
|
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
|
role: Mapped[str] = mapped_column(String(16), default="viewer") # owner|editor|viewer
|
||||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending | accepted
|
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|accepted
|
||||||
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
||||||
@@ -110,43 +120,57 @@ class ProjectMember(Base):
|
|||||||
class ProjectSong(Base):
|
class ProjectSong(Base):
|
||||||
__tablename__ = "project_songs"
|
__tablename__ = "project_songs"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
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)
|
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)
|
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|played|skipped
|
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")
|
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
|
||||||
song: Mapped["Song"] = relationship("Song", 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 ───────────────────────────────────────────────────────
|
# ── Community dans-tags ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
class CommunityDance(Base):
|
class CommunityDance(Base):
|
||||||
"""Fællesskabets dans-tags på sange — identificeret ved mbid eller titel+artist."""
|
"""Fællesskabets dans-tags på sange."""
|
||||||
__tablename__ = "community_dances"
|
__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)
|
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_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||||
song_title: Mapped[str] = mapped_column(String(255), default="")
|
song_title: Mapped[str] = mapped_column(String(255), default="")
|
||||||
song_artist: 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)
|
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)
|
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
dance: Mapped["Dance"] = relationship("Dance")
|
dance: Mapped["Dance"] = relationship("Dance")
|
||||||
|
|
||||||
|
|
||||||
# ── Community alternativ-dans + rating ────────────────────────────────────────
|
|
||||||
|
|
||||||
class CommunityDanceAlt(Base):
|
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"
|
__tablename__ = "community_dance_alts"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "alt_dance_id", name="uq_comm_alt"),)
|
||||||
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)
|
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_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)
|
rating_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
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")
|
ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="alternative", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class DanceAltRating(Base):
|
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"
|
__tablename__ = "dance_alt_ratings"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),)
|
||||||
UniqueConstraint("alternative_id", "user_id", name="uq_rating"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
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)
|
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False)
|
||||||
|
|||||||
@@ -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.security import OAuth2PasswordRequestForm
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
from app.core.database import get_db
|
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.models import User
|
||||||
from app.schemas import UserCreate, UserOut, Token
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserOut, status_code=201)
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
def register(data: UserCreate, db: Session = Depends(get_db)):
|
|
||||||
|
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():
|
if db.query(User).filter(User.username == data.username).first():
|
||||||
raise HTTPException(400, "Brugernavnet er allerede i brug")
|
raise HTTPException(400, "Brugernavnet er allerede i brug")
|
||||||
if db.query(User).filter(User.email == data.email).first():
|
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(
|
user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
|
full_name=data.full_name,
|
||||||
password_hash=hash_password(data.password),
|
password_hash=hash_password(data.password),
|
||||||
|
is_verified=False,
|
||||||
|
verify_token=token,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
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("""
|
||||||
|
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
|
||||||
|
<h2>❌ Ugyldigt eller udløbet link</h2>
|
||||||
|
<p>Prøv at registrere dig igen.</p>
|
||||||
|
</body></html>
|
||||||
|
""", status_code=400)
|
||||||
|
|
||||||
|
user.is_verified = True
|
||||||
|
user.verify_token = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return HTMLResponse("""
|
||||||
|
<html><body style="font-family:sans-serif;text-align:center;padding:60px;
|
||||||
|
background:#1a1d23;color:#e0e4f0">
|
||||||
|
<h2 style="color:#e8a020">✓ E-mail bekræftet!</h2>
|
||||||
|
<p>Din konto er nu aktiv. Du kan logge ind i LineDance Player.</p>
|
||||||
|
<p style="color:#5a6070;font-size:14px">Du kan lukke dette vindue.</p>
|
||||||
|
</body></html>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
def login(
|
||||||
user = db.query(User).filter(User.username == form.username).first()
|
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):
|
if not user or not verify_password(form.password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(401, "Forkert brugernavn eller kodeord")
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="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})
|
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."}
|
||||||
|
|||||||
18
linedance-api/docker-compose.yml
Normal file
18
linedance-api/docker-compose.yml
Normal file
@@ -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"
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ pydantic[email]>=2.0.0
|
|||||||
pydantic-settings>=2.0.0
|
pydantic-settings>=2.0.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
aiosmtplib>=3.0.0
|
||||||
# Lokalt data-lag
|
jinja2>=3.1.0
|
||||||
mutagen>=1.47.0
|
cryptography>=42.0.0
|
||||||
watchdog>=4.0.0
|
|
||||||
|
|||||||
24
linedance-api/start.sh
Executable file
24
linedance-api/start.sh
Executable file
@@ -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
|
||||||
@@ -148,97 +148,22 @@ class LibraryWatcher:
|
|||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
def add_library(self, path: str) -> int:
|
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)
|
library_id = add_library(path)
|
||||||
|
|
||||||
if self._observer and self._running:
|
def _bg(lib_id, lib_path):
|
||||||
handler = MusicLibraryHandler(library_id, self.on_change)
|
# Registrer watchdog — kan blokere lidt på Windows med mange filer
|
||||||
self._observer.schedule(handler, path, recursive=True)
|
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
|
# Scan
|
||||||
def _scan_in_background(lib_id, lib_path):
|
self._full_scan_library(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
|
|
||||||
|
|
||||||
# Åbn egen forbindelse — deler ikke med GUI-tråden
|
t = threading.Thread(target=_bg, args=(library_id, path), daemon=True)
|
||||||
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.start()
|
t.start()
|
||||||
return library_id
|
return library_id
|
||||||
|
|
||||||
|
|||||||
@@ -107,19 +107,35 @@ class LibraryManagerDialog(QDialog):
|
|||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
# Ryd eksisterende widgets
|
|
||||||
while self._libs_layout.count():
|
while self._libs_layout.count():
|
||||||
item = self._libs_layout.takeAt(0)
|
item = self._libs_layout.takeAt(0)
|
||||||
if item.widget():
|
if item.widget():
|
||||||
item.widget().deleteLater()
|
item.widget().deleteLater()
|
||||||
|
|
||||||
try:
|
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)
|
libs = get_libraries(active_only=True)
|
||||||
for lib in libs:
|
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:
|
except Exception as e:
|
||||||
pass
|
lbl = QLabel(f"Fejl: {e}")
|
||||||
|
self._libs_layout.addWidget(lbl)
|
||||||
|
|
||||||
def _make_lib_row(self, lib: dict) -> QFrame:
|
def _make_lib_row(self, lib: dict) -> QFrame:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -137,21 +153,8 @@ class LibraryManagerDialog(QDialog):
|
|||||||
last_scan = lib.get("last_full_scan") or "aldrig"
|
last_scan = lib.get("last_full_scan") or "aldrig"
|
||||||
if isinstance(last_scan, str) and len(last_scan) > 10:
|
if isinstance(last_scan, str) and len(last_scan) > 10:
|
||||||
last_scan = last_scan[:10]
|
last_scan = last_scan[:10]
|
||||||
try:
|
total = lib.get("_count", 0)
|
||||||
from local.local_db import get_db
|
missing_bpm = lib.get("_missing_bpm", 0)
|
||||||
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
|
|
||||||
|
|
||||||
lbl_path = QLabel(("⚠ " if not exists else "") + path)
|
lbl_path = QLabel(("⚠ " if not exists else "") + path)
|
||||||
lbl_path.setObjectName("track_title" if exists else "result_count")
|
lbl_path.setObjectName("track_title" if exists else "result_count")
|
||||||
@@ -175,13 +178,13 @@ class LibraryManagerDialog(QDialog):
|
|||||||
btn_row.setSpacing(6)
|
btn_row.setSpacing(6)
|
||||||
|
|
||||||
btn_scan = QPushButton("⟳ Fil-scan")
|
btn_scan = QPushButton("⟳ Fil-scan")
|
||||||
btn_scan.setFixedHeight(24)
|
btn_scan.setFixedHeight(30)
|
||||||
btn_scan.setToolTip("Scan for nye og ændrede filer")
|
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_scan.clicked.connect(lambda _, lid=lib_id, p=path: self._scan_files(lid, p))
|
||||||
btn_row.addWidget(btn_scan)
|
btn_row.addWidget(btn_scan)
|
||||||
|
|
||||||
btn_bpm = QPushButton(f"♩ BPM manglende ({missing_bpm})")
|
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.setToolTip("Analysér BPM på sange der mangler det")
|
||||||
btn_bpm.setEnabled(missing_bpm > 0)
|
btn_bpm.setEnabled(missing_bpm > 0)
|
||||||
btn_bpm.clicked.connect(
|
btn_bpm.clicked.connect(
|
||||||
@@ -190,7 +193,7 @@ class LibraryManagerDialog(QDialog):
|
|||||||
btn_row.addWidget(btn_bpm)
|
btn_row.addWidget(btn_bpm)
|
||||||
|
|
||||||
btn_bpm_all = QPushButton("♩ BPM alle")
|
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.setToolTip("Genanalysér BPM på alle sange (overskriver eksisterende)")
|
||||||
btn_bpm_all.clicked.connect(
|
btn_bpm_all.clicked.connect(
|
||||||
lambda _, lid=lib_id, b=btn_bpm_all, s=lbl_status: self._start_bpm(lid, True, b, s)
|
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_row.addStretch()
|
||||||
|
|
||||||
btn_remove = QPushButton("✕ Fjern")
|
btn_remove = QPushButton("✕ Fjern")
|
||||||
btn_remove.setFixedHeight(24)
|
btn_remove.setFixedHeight(30)
|
||||||
btn_remove.clicked.connect(lambda _, l=lib: self._remove_library(l))
|
btn_remove.clicked.connect(lambda _, l=lib: self._remove_library(l))
|
||||||
btn_row.addWidget(btn_remove)
|
btn_row.addWidget(btn_remove)
|
||||||
|
|
||||||
|
|||||||
131
linedance-app/ui/register_dialog.py
Normal file
131
linedance-app/ui/register_dialog.py
Normal file
@@ -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)
|
||||||
@@ -20,6 +20,7 @@ SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
|
|||||||
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
|
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
|
||||||
SETTINGS_KEY_USERNAME = "online/username"
|
SETTINGS_KEY_USERNAME = "online/username"
|
||||||
SETTINGS_KEY_PASSWORD = "online/password"
|
SETTINGS_KEY_PASSWORD = "online/password"
|
||||||
|
SETTINGS_KEY_SERVER_URL = "online/server_url"
|
||||||
SETTINGS_KEY_LANGUAGE = "appearance/language"
|
SETTINGS_KEY_LANGUAGE = "appearance/language"
|
||||||
SETTINGS_KEY_BETWEEN_SEC = "playback/between_seconds"
|
SETTINGS_KEY_BETWEEN_SEC = "playback/between_seconds"
|
||||||
SETTINGS_KEY_WORKSHOP_MIN = "playback/workshop_minutes"
|
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),
|
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
|
||||||
"username": s.value(SETTINGS_KEY_USERNAME, ""),
|
"username": s.value(SETTINGS_KEY_USERNAME, ""),
|
||||||
"password": s.value(SETTINGS_KEY_PASSWORD, ""),
|
"password": s.value(SETTINGS_KEY_PASSWORD, ""),
|
||||||
|
"server_url": s.value(SETTINGS_KEY_SERVER_URL, "http://localhost:8000"),
|
||||||
"language": s.value(SETTINGS_KEY_LANGUAGE, "da"),
|
"language": s.value(SETTINGS_KEY_LANGUAGE, "da"),
|
||||||
"between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int),
|
"between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int),
|
||||||
"workshop_minutes": s.value(SETTINGS_KEY_WORKSHOP_MIN, 10, 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_AUTO_LOGIN, values.get("auto_login", False))
|
||||||
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
|
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
|
||||||
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
|
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_LANGUAGE, values.get("language", "da"))
|
||||||
s.setValue(SETTINGS_KEY_BETWEEN_SEC, values.get("between_seconds", 60))
|
s.setValue(SETTINGS_KEY_BETWEEN_SEC, values.get("between_seconds", 60))
|
||||||
s.setValue(SETTINGS_KEY_WORKSHOP_MIN,values.get("workshop_minutes", 10))
|
s.setValue(SETTINGS_KEY_WORKSHOP_MIN,values.get("workshop_minutes", 10))
|
||||||
@@ -230,15 +233,31 @@ class SettingsDialog(QDialog):
|
|||||||
layout = QVBoxLayout(tab)
|
layout = QVBoxLayout(tab)
|
||||||
layout.setSpacing(12)
|
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)
|
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 = QCheckBox("Log automatisk ind når programmet starter")
|
||||||
self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed)
|
self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed)
|
||||||
grp_layout.addRow(self._chk_auto_login)
|
grp_layout.addRow(self._chk_auto_login)
|
||||||
|
|
||||||
self._user_input = QLineEdit()
|
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)
|
grp_layout.addRow("Brugernavn:", self._user_input)
|
||||||
|
|
||||||
self._pass_input = QLineEdit()
|
self._pass_input = QLineEdit()
|
||||||
@@ -257,6 +276,12 @@ class SettingsDialog(QDialog):
|
|||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
return tab
|
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:
|
def _build_language_tab(self) -> QWidget:
|
||||||
tab = QWidget()
|
tab = QWidget()
|
||||||
layout = QVBoxLayout(tab)
|
layout = QVBoxLayout(tab)
|
||||||
@@ -311,6 +336,7 @@ class SettingsDialog(QDialog):
|
|||||||
self._chk_auto_login.setChecked(auto)
|
self._chk_auto_login.setChecked(auto)
|
||||||
self._user_input.setText(v.get("username", ""))
|
self._user_input.setText(v.get("username", ""))
|
||||||
self._pass_input.setText(v.get("password", ""))
|
self._pass_input.setText(v.get("password", ""))
|
||||||
|
self._server_url.setText(v.get("server_url", "http://localhost:8000"))
|
||||||
self._user_input.setEnabled(auto)
|
self._user_input.setEnabled(auto)
|
||||||
self._pass_input.setEnabled(auto)
|
self._pass_input.setEnabled(auto)
|
||||||
|
|
||||||
@@ -328,6 +354,7 @@ class SettingsDialog(QDialog):
|
|||||||
"auto_login": self._chk_auto_login.isChecked(),
|
"auto_login": self._chk_auto_login.isChecked(),
|
||||||
"username": self._user_input.text().strip(),
|
"username": self._user_input.text().strip(),
|
||||||
"password": self._pass_input.text(),
|
"password": self._pass_input.text(),
|
||||||
|
"server_url": self._server_url.text().strip() or "http://localhost:8000",
|
||||||
"language": self._lang_combo.currentData(),
|
"language": self._lang_combo.currentData(),
|
||||||
}
|
}
|
||||||
save_settings(values)
|
save_settings(values)
|
||||||
|
|||||||
Reference in New Issue
Block a user