o
    oi)                     @  s:  U d Z ddlmZ ddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlZddlZddlZddlmZ ddlmZmZmZ ddlmZ ddlmZ ddlmZmZ ed	Zee jZejZ e d
 Z!ej"#dsxe!j$dd eej"#de%e!d Z&eej"#de%e!d Z'ed Z(dZ)dZ*dZ+dZ,dZ-dZ.e/dZ0dZ1e2 Z3dddZ4edd Z5dd!d"Z6dd$d%Z7dd)d*Z8dd-d.Z9dd/d0Z:dd1d2Z;dd5d6Z<G d7d8 d8e=Z>dd:d;Z?dd>d?Z@dd@dAZAddFdGZBdddKdLZCddMdNZDddPdQZEdddTdUZF			Vddd]d^ZGdddbdcZHddgdhZIddidjZJddndoZKddrdsZLdddudvZM		dddzd{ZN	VddddZOdZP	ddddZQ	VddddZRddddZSdddZTddddZUddddZVddddZWdddZXddddZYdaZde[d< e\ Z]dddZ^dddZ_dddZ`dddZadS )u  
licensing.py  –  EEG-Assistent · Lizenzierungs-Kern

Kapselt alle Zugriffe auf die SQLite-Datenbank `tokens.db`:
  • UID-Validierung + Session-Lock (keine Parallel-Nutzung einer UID)
  • Token-Konten + atomarer Abzug beim Download
  • Nutzungsprotokoll (IP, Fingerprint, User-Agent, Modul-Auswahl)
  • Admin-Funktionen (Konten anlegen, Tokens anpassen, aktivieren)
  • Rate-Limit für Login-Versuche
  • Automatisches tägliches Backup nach tokens_backups/
    )annotationsN)contextmanager)datetime	timedeltatimezone)Path)Iterable)generate_password_hashcheck_password_hash	licensingdataABRECHNUNG_LICENSE_DBT)exist_okz	tokens.dbABRECHNUNG_LICENSE_BACKUP_DIRtokens_backupszlicensing_schema.sqliG  i   i  
      Q z*^EEGA-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$EEGAreturnsqlite3.Connectionc                  C  sh   t tdd} | du r2tjjddd tjttddd} tj| _	| 
d | 
d | 
d	 | t_| S )
zELiefert eine thread-lokale Connection mit Row-Factory + Foreign-Keys.connNTparentsr   r   )timeoutisolation_levelzPRAGMA foreign_keys = ONzPRAGMA journal_mode = WALzPRAGMA synchronous  = NORMAL)getattr_tlsDB_PATHparentmkdirsqlite3connectstrRowrow_factoryexecuter   )r    r'   _/sessions/sharp-trusting-curie/mnt/energie-durchdacht/EEG-Abrechnung/v1.07b_upload/licensing.py_connectC   s   


r)   c                  c  sD    t  } | d z| V  | d W dS  ty!   | d  w )uA   Kontext-Manager für eine exklusive Transaktion (IMMEDIATE-Lock).zBEGIN IMMEDIATECOMMITROLLBACKN)r)   r&   	Exceptioncr'   r'   r(   _txQ   s   

r/   Nonec                  C  sl   t  } t stdt ttddd}| |  W d   n1 s'w   Y  tdt	  dS )z-Legt Schema + Default-Pakete an (idempotent).zSchema-Datei fehlt: rutf-8)encodingNzDB initialisiert: )
r)   SCHEMA_PATHexistsRuntimeErroropenexecutescriptreadloginfor   )r.   fr'   r'   r(   init_db^   s   r=   r#   c                   C  s   t tjjdd S )u.   ISO-8601 UTC-Timestamp mit Sekundenauflösung.r   microsecond)r   nowr   utcreplace	isoformatr'   r'   r'   r(   _nowj      rD   ts
str | Nonedatetime | Nonec                 C  s*   | sd S zt | W S  ty   Y d S w N)r   fromisoformatr,   )rF   r'   r'   r(   	_parse_tso   s   rK   uidboolc                 C  s    t | tott|   S rI   )
isinstancer#   rM   _UID_REmatchstripupperrL   r'   r'   r(   _valid_uid_formatx   s    rT   c                 C  s   | pd   S )z<Trimmt und uppercase'd; Server-seitig wird damit verglichen. )rQ   rR   rS   r'   r'   r(   _normalize_uid|   s   rV   c               	   C  s@   t d } t d| dd  d| dd  d| dd  S )zANeue UID im Format EEGA-XXXX-XXXX-XXXX (12 Hex-Zeichen = 48 Bit).   -r            )secrets	token_hexrR   _PREFIX)rawr'   r'   r(   generate_uid   s   2r`   abc                 C  s   t | d|dS )uA   Konstante Zeit für UID-Vergleich — beugt Timing-Angriffen vor.r2   )hmaccompare_digestencode)ra   rb   r'   r'   r(   constant_time_eq   rE   rf   c                      s"   e Zd ZdZd fddZ  ZS )LicenseErroru   Wird geworfen, wenn eine UID abgelehnt werden muss. .reason ist ein
    stabiler String-Code für die UI ('invalid', 'disabled', 'expired',
    'locked', 'rate_limited').reasonr#   msgc                   s   t  | || _d S rI   )super__init__rh   )selfrh   ri   	__class__r'   r(   rk      s   
zLicenseError.__init__)rh   r#   ri   r#   )__name__
__module____qualname____doc__rk   __classcell__r'   r'   rm   r(   rg      s    rg   ipc                 C  sf   | sdS t  }ttjttd  }|d| |f	 }|r/|d t
kr1tdd|  ddS dS )zCWirft LicenseError('rate_limited'), wenn die IP zu oft daneben lag.NsecondszFSELECT COUNT(*) AS n FROM failed_logins WHERE ip = ? AND timestamp > ?nrate_limitedzZu viele Fehlversuche von u    — bitte später erneut.)r)   r   r@   r   rA   r   LOGIN_RATE_WINDOW_SrC   r&   fetchoneLOGIN_RATE_MAX_FAILSrg   )rt   r.   cutoffrowr'   r'   r(   check_rate_limit   s&   

r~   uid_attemptedrh   c                 C  s0   t  }|d| p	d|pdd d t |f d S )NzTINSERT INTO failed_logins (ip, uid_attempted, timestamp, reason) VALUES (?, ?, ?, ?)rU   (   r)   r&   rD   )rt   r   rh   r.   r'   r'   r(   _record_failed_login   s
   r   c                  C  s   t  } | dt f dS )z1Entfernt Session-Locks, deren TTL abgelaufen ist.z0DELETE FROM active_sessions WHERE expires_at < ?Nr   r-   r'   r'   r(   _cleanup_stale_sessions   s   r   uid_rawfingerprint
user_agentdictc                 C  s0  t | t  t| pd}|pd dd }t|s&t||d tddt }|d|f	 }|du rBt||d tdd|d d	krSt||d	 td	d
t
|d }|ruttj|kru|d|f t||d tdd|d|t f	 }|durt||d tddd}	|d r|r||d krdnd}	|	dkr|d|f n
|r|d||f t j}
t }ttjttd jdd }|d||
|||pd|pdf |d|||pd|pddd |pd|	|
|d f |d|f	 }W d   n	1 sw   Y  t||
S )u  Prüft UID und legt eine neue Session an. Gibt dict mit allen Daten
    zurück, die das Frontend für die Header-Anzeige braucht.

    Raises LicenseError mit .reason in:
      'invalid'       – Format falsch oder UID unbekannt
      'disabled'      – Admin hat deaktiviert
      'expired'       – Ablaufdatum überschritten
      'locked'        – Auf einem anderen Gerät aktiv (keine Parallel-Nutzung)
      'rate_limited'  – zu viele Fehlversuche von dieser IP
    rU   N   invalidu   Ungültiges Code-Format.z$SELECT * FROM accounts WHERE uid = ?zCode nicht bekannt.statusdisabledzCode wurde deaktiviert.
expires_at0UPDATE accounts SET status='expired' WHERE uid=?expiredCode ist abgelaufen.z>SELECT * FROM active_sessions WHERE uid = ? AND expires_at > ?lockedu6   Dieser Code ist gerade auf einem anderen Gerät aktiv.initial_fingerprint   r   zUUPDATE accounts SET fingerprint_mismatches = fingerprint_mismatches + 1 WHERE uid = ?z9UPDATE accounts SET initial_fingerprint = ? WHERE uid = ?ru   r>   zuINSERT INTO active_sessions (uid, session_id, started_at, expires_at, ip, fingerprint_hash) VALUES (?, ?, ?, ?, ?, ?)zINSERT INTO usage_log (account_uid, timestamp, ip, user_agent, fingerprint_hash, fingerprint_match, session_id, periode, modules, tokens_consumed, remaining_after, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, ?, 'login')  remaining_tokens)r~   r   rV   rQ   rT   r   rg   r/   r&   rz   rK   r   r@   r   rA   rD   uuiduuid4hexr   SESSION_TTL_SECONDSrB   rC   _account_public_view)r   r   rt   r   rL   r.   r}   explock
match_flag
session_idstartedexpiresr'   r'   r(   login   s   







Dr   r}   sqlite3.Rowr   c                 C  s\   | d }|dd  d|dd  d|dd  }||| d	 | d
 | d | d | d dS )uf   Das, was das Frontend vom Konto sehen darf — KEIN Fingerprint, keine
    Notizen, keine IP-Historie.rL   NrY   rX      	      -…-customer_namer   r   first_used_atr   )r   
uid_maskedr   r   r   r   r   r'   )r}   r   rL   maskedr'   r'   r(   r     s   ,r   c                 C  s    | sdS t  }|d| f dS )zSession-Lock freigeben.Nz0DELETE FROM active_sessions WHERE session_id = ?)r)   r&   )r   r.   r'   r'   r(   logout"  s   r   dict | Nonec                 C  sr   | sdS t   t }|d| t f }|du rdS ttjt	t
d jdd }|d|| f t|| S )uC   Liefert den aktuellen Session-State oder None. Verlängert das TTL.NnSELECT a.* FROM accounts a JOIN active_sessions s ON s.uid = a.uid WHERE s.session_id = ? AND s.expires_at > ?ru   r   r>   z>UPDATE active_sessions SET expires_at = ? WHERE session_id = ?)r   r)   r&   rD   rz   r   r@   r   rA   r   r   rB   rC   r   )r   r.   r}   new_expr'   r'   r(   get_session*  s.   

r   only_active
list[dict]c                 C  s6   t  }d}| r|d7 }|d7 }dd || D S )NzHSELECT key, name, cost, is_active, sort_order, description FROM packagesz WHERE is_active = 1z ORDER BY sort_order, keyc                 S     g | ]}t |qS r'   r   .0r1   r'   r'   r(   
<listcomp>H      z!list_packages.<locals>.<listcomp>)r)   r&   fetchall)r   r.   sqlr'   r'   r(   list_packagesB  s   r   systemkeycost
int | None	is_activebool | None
admin_userc           	      C  s   | pd   } | std|du r|du rtdt i}|d| f }|du r3td|  d|dur;t|n|d }|d	k rGtd
|durQtt|n|d }|d||| f t|ddt	
| |d ||d |d |d| f }t|W  d   S 1 sw   Y  dS )u   Admin-API: Preis und/oder Aktiv-Flag eines Pakets anpassen.

    ``cost`` muss >= 0 sein, ``is_active`` wird auf 0/1 normalisiert. Änderungen
    landen im admin_audit-Log. Gibt das aktualisierte Paket zurück.
    rU   zPaket-Key fehlt.Nz5Mindestens cost oder is_active muss angegeben werden.z$SELECT * FROM packages WHERE key = ?zPaket 'z' nicht gefunden.r   r   zcost muss >= 0 sein.r   z9UPDATE packages SET cost = ?, is_active = ? WHERE key = ?update_package)r   old_costnew_cost
old_active
new_activezVSELECT key, name, cost, is_active, sort_order, description FROM packages WHERE key = ?)rQ   lower
ValueErrorr/   r&   rz   intrM   _admin_auditjsondumpsr   )	r   r   r   r   r.   r}   r   r   updatedr'   r'   r(   r   K  sB   $r      limitr   c                 C  s*   t  }|dt| f }dd |D S )u#   Admin-Audit-Log für das Dashboard.znSELECT id, admin_user, timestamp, action, target_uid, details FROM admin_audit ORDER BY timestamp DESC LIMIT ?c                 S  r   r'   r   r   r'   r'   r(   r   y  r   z$list_admin_audit.<locals>.<listcomp>)r)   r&   r   r   )r   r.   rowsr'   r'   r(   list_admin_auditq  s   r   module_keysIterable[str]dict[str, int]c                 C  sZ   t dd | D }|si S t }ddd |D }|d| d| }dd	 |D S )
uB   Liefert {key: cost} für die angegebenen Keys (nur aktive Pakete).c                 S     h | ]}|r|qS r'   r'   )r   kr'   r'   r(   	<setcomp>~  r   z$get_package_costs.<locals>.<setcomp>,c                 s  s    | ]}d V  qdS )?Nr'   )r   _r'   r'   r(   	<genexpr>  s    z$get_package_costs.<locals>.<genexpr>z?SELECT key, cost FROM packages WHERE is_active = 1 AND key IN ()c                 S  s   i | ]	}|d  |d qS )r   r   r'   r   r'   r'   r(   
<dictcomp>  s    z%get_package_costs.<locals>.<dictcomp>)listr)   joinr&   r   )r   keysr.   placeholderr   r'   r'   r(   get_package_costs|  s   
r   c                 C  s   t t|  S rI   )sumr   values)r   r'   r'   r(   
total_cost  s   r   modules	list[str]periodec                   s  | st ddtdd |pg D }t|  fdd|D }|r+t dd|d  t  }t 	}|d	| t f }	|	d
u rJt dd|	d dkr]t |	d d|	d  dt	|	d }
|
r{t
tj|
kr{|d|	d f t dd|	d |k rt dd|	d  d| d|	d | }t }|	d st
tjttd jdd }|d||||	d f |}n|d||	d f |	d }d
}|	d r|r||	d krd nd}|dkr|d!|	d f |d"|	d ||pd#|pd#d
d$ |pd#|| |pd#t|||f |d%| f |	d d
d&  d'|	d d(d)  d*|	d d+d
  |	d, |||d-W  d
   S 1 sBw   Y  d
S ).u  Wird direkt vor dem send_file() in /api/v1/download aufgerufen.

    Führt in EINER Transaktion durch:
      1) Session validieren + Konto laden
      2) Status prüfen (active, nicht abgelaufen, Tokens reichen)
      3) Tokens abziehen, first_used_at/expires_at setzen falls NULL
      4) usage_log-Eintrag schreiben
      5) Session-Lock freigeben (Download beendet die aktive Verarbeitung)

    Raises LicenseError bei allen Fehlern. Liefert dict mit den neuen
    Kontostand-Daten bei Erfolg.
    r   zKeine Session.c                 S  r   r'   r'   r   mr'   r'   r(   r     r   z!consume_tokens.<locals>.<setcomp>c                   s   g | ]}| vr|qS r'   r'   r   costsr'   r(   r     s    z"consume_tokens.<locals>.<listcomp>zUnbekanntes Paket: r   r   Nz&Session ist abgelaufen oder unbekannt.r   activezKonto-Status: .r   r   rL   r   r   r   	no_tokenszNicht genug Tokens: u    verfügbar, u    nötig.r   )daysr>   zQUPDATE accounts SET remaining_tokens=?, first_used_at=?, expires_at=? WHERE uid=?2UPDATE accounts SET remaining_tokens=? WHERE uid=?r   r   zSUPDATE accounts SET fingerprint_mismatches = fingerprint_mismatches + 1 WHERE uid=?zINSERT INTO usage_log (account_uid, timestamp, ip, user_agent, fingerprint_hash, fingerprint_match, session_id, periode, modules, tokens_consumed, remaining_after, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'download')rU   r   z.DELETE FROM active_sessions WHERE session_id=?rY   rX   r   r   r   r   r   )r   r   tokens_consumedr   r   )rg   sortedr   r   r   r/   r&   rD   rz   rK   r   r@   r   rA   r   VALIDITY_DAYSrB   rC   r   r   )r   r   rt   r   r   r   unknownamountr.   r}   r   new_balancenow_iso
new_expiryexpiry_to_returnr   r'   r   r(   consume_tokens  s   






6&r   usernamepasswordc                 C  sd   | r
|r
t |dk rtdt }|d| f }|r$td|  d|d| t|t f d S )NrZ   z2Username + Passwort (min. 8 Zeichen) erforderlich.z%SELECT 1 FROM admins WHERE username=?zAdmin 'z' existiert bereits.zCINSERT INTO admins (username, pw_hash, created_at) VALUES (?, ?, ?))lenr   r)   r&   rz   r	   rD   )r   r   r.   existingr'   r'   r(   create_admin  s   r   rU   c                 C  s`   t  }|d| f }|std|pd dS t|d |pd}|r.|dt |p*d| f |S )Nz+SELECT pw_hash FROM admins WHERE username=?z[pbkdf2:sha256:600000$dummy$0000000000000000000000000000000000000000000000000000000000000000rU   Fpw_hashzCUPDATE admins SET last_login_at=?, last_login_ip=? WHERE username=?)r)   r&   rz   r
   rD   )r   r   rt   r.   r}   okr'   r'   r(   verify_admin  s   r  action
target_uiddetailsc                 C  s"   t  }|d| t |||f d S )NzcINSERT INTO admin_audit (admin_user, timestamp, action, target_uid, details) VALUES (?, ?, ?, ?, ?)r   )r   r  r  r  r.   r'   r'   r(   r     s
   r   r   customer_emaildevice_nameinitial_tokensnotesc                 C  s   | st d|d u s|dk rt dt }t E}|d|f r.t }|d|f s"|d|| |p6d |p9d t|t|t |pDd f t|d|t	|| |d W d    |S 1 s`w   Y  |S )Nzcustomer_name ist Pflicht.r   zinitial_tokens muss >= 0 sein.z"SELECT 1 FROM accounts WHERE uid=?zINSERT INTO accounts (uid, customer_name, customer_email, device_name, initial_tokens, remaining_tokens, created_at, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)create_account)tokenscustomerdevice)
r   r`   r/   r&   rz   r   rD   r   r   r   )r   r  r  r  r	  r   rL   r.   r'   r'   r(   r
  )  s2   
r
  4   c                 C  s   t | |dtdt dddS )af  Legt einen Account mit dem Inklusiv-Paket der Vereins-Gruendung an.

    Wird vom Onboarding-Tool nach erfolgreicher Bezahlung aufgerufen.
    Token-Anzahl ist hardcoded (GRUENDUNG_TOKENS = 52), damit die Inklusiv-
    Menge nicht versehentlich ueber die packages-Tabelle verschoben wird.

    Rueckgabe: die generierte UID (Format EEGA-XXXX-XXXX-XXXX).
    Nz Inklusiv-Paket Gruendung (auto, z Tokens)
onboarding)r   r  r  r  r	  r   )r
  GRUENDUNG_TOKENS)r   r  r'   r'   r(   create_gruendungs_accountH  s   

r  deltac                 C  s   t | } t| stdt f}|d| f }|du r%td|  d|d t| }|dk r>td|d  d	| d
|d|| f |d| t t| |t	||df t
|d| t	|||d | |dW  d   S 1 sxw   Y  dS )uF   Ändert den Token-Stand um +/- delta. Darf nicht ins Negative führen.u   Ungültiges UID-Format.z"SELECT * FROM accounts WHERE uid=?NUID  nicht gefunden.r   r   u/   Anpassung würde Saldo negativ machen (aktuell z, delta z).r   zINSERT INTO usage_log (account_uid, timestamp, tokens_consumed, remaining_after, event_type, modules) VALUES (?, ?, ?, ?, 'adjust', ?))rh   byadjust_tokens)r  rh   r   )rL   r   )rV   rT   r   r/   r&   rz   r   rD   r   r   r   )rL   r  rh   r   r.   r}   r   r'   r'   r(   r  [  s<   
$r  r   c                 C  s   |dvrt dt| } t 2}|d|| f}|jdkr%t d|  d|dkr0|d| f t|d	| |  W d    d S 1 sDw   Y  d S )
N)r   r   r   z)Status muss active/disabled/expired sein.z(UPDATE accounts SET status=? WHERE uid=?r   r  r  r   z'DELETE FROM active_sessions WHERE uid=?zset_status:)r   rV   r/   r&   rowcountr   )rL   r   r   r.   r1   r'   r'   r(   
set_statusy  s   
"r  c                 C  s4   t | } t }|d| t f }|r|d S dS )uD   Gibt das expires_at der aktiven Session für uid zurück, oder None.zhSELECT expires_at FROM active_sessions WHERE uid = ? AND expires_at > ? ORDER BY expires_at DESC LIMIT 1r   N)rV   r)   r&   rD   rz   )rL   r.   r}   r'   r'   r(   get_lock_expires  s   r  c              	   C  s   t | } t| std|  t }|d| f }|du r'td|  d|d| fj}t|d| t	d|i t
d	| d
|  d| d |S )ut   Löscht alle aktiven Sessions für uid (entsperrt das Gerät).
    Gibt die Anzahl der gelöschten Sessions zurück.u   Ungültige UID: &SELECT uid FROM accounts WHERE uid = ?NzKonto r  )DELETE FROM active_sessions WHERE uid = ?unlock_devicedeleted_sessionszAdmin u   : Device-Lock für z aufgehoben (u    Session(s) gelöscht))rV   rT   r   r)   r&   rz   r  r   r   r   r:   r;   rL   r   r.   r}   deletedr'   r'   r(   r    s   
r  include_disabledc                 C  sD   t  }t }d}|g}| s|d7 }|d7 }dd ||| D S )Na^  SELECT a.uid, a.customer_name, a.customer_email, a.device_name, a.initial_tokens, a.remaining_tokens, a.created_at, a.first_used_at, a.expires_at, a.status, a.fingerprint_mismatches, a.notes, (SELECT s.expires_at FROM active_sessions s  WHERE s.uid = a.uid AND s.expires_at > ?  ORDER BY s.expires_at DESC LIMIT 1) AS lock_expires_at FROM accounts a zWHERE a.status != 'disabled' zORDER BY a.created_at DESCc                 S  r   r'   r   r   r'   r'   r(   r     r   z!list_accounts.<locals>.<listcomp>)r)   rD   r&   r   )r   r.   r@   r   paramsr'   r'   r(   list_accounts  s   	r"  c                 C  sL   t  }| r|dt| t|f }n|dt|f }dd |D S )NzMSELECT * FROM usage_log WHERE account_uid = ? ORDER BY timestamp DESC LIMIT ?z7SELECT * FROM usage_log ORDER BY timestamp DESC LIMIT ?c                 S  r   r'   r   r   r'   r'   r(   r     r   z!get_usage_log.<locals>.<listcomp>)r)   r&   rV   r   r   )rL   r   r.   r   r'   r'   r(   get_usage_log  s   r#  c                 C  sh   t | } t }|d| f }|du rtd|  d|d| f }| t t|dd |D dS )	u   Liefert alle zu einem Konto gespeicherten Daten als Dict.

    Inkl. Stammdaten + komplettem Nutzungsprotokoll. Ziel: Auskunftsersuchen
    nach DSGVO Art. 15 vollständig beantworten zu können (JSON-Export).
    zSELECT uid, customer_name, customer_email, device_name, initial_tokens, remaining_tokens, created_at, first_used_at, expires_at, status, fingerprint_mismatches, notes FROM accounts WHERE uid = ?Nr  r  zSELECT id, timestamp, ip, user_agent, fingerprint_hash, fingerprint_match, session_id, periode, modules, tokens_consumed, remaining_after, event_type FROM usage_log WHERE account_uid = ? ORDER BY timestamp ASCc                 S  r   r'   r   r   r'   r'   r(   r     r   z'export_account_data.<locals>.<listcomp>)rL   exported_ataccount	usage_log)rV   r)   r&   rz   r   r   rD   r   )rL   r.   r}   usager'   r'   r(   export_account_data  s*   r(  c              	   C  s   t | } t G}|d| f }|du rtd|  d|d| fj}|d| f |ddt  d	| | f t|d
| t	d|i W d   n1 sQw   Y  t
d|| | | |ddS )ux  DSGVO-konforme Löschung: Personenbezogene Felder werden anonymisiert,
    das Nutzungsprotokoll wird gelöscht, aktive Sessions werden gekillt, der
    Status auf ``disabled`` gesetzt.

    Der UID-Datensatz bleibt mit leeren Feldern bestehen, damit bereits
    ausgestellte Rechnungen / Quittungen weiterhin referenzierbar bleiben.
    Die Aktion landet im admin_audit.
    r  Nr  r  z+DELETE FROM usage_log WHERE account_uid = ?r  us   UPDATE accounts SET customer_name = '[gelöscht]', device_name = NULL, notes = ?, status = 'disabled' WHERE uid = ?u   DSGVO-Löschung am z durch delete_accountusage_rows_deletedu4   Admin %s: DSGVO-Löschung für %s (%d Log-Einträge)r   )rL   r*  r   )rV   r/   r&   rz   r   r  rD   r   r   r   r:   r;   r  r'   r'   r(   r)    s.   		r)  zthreading.Thread | None_BACKUP_THREADPath | Nonec               	   C  s(  zt jddd t sW dS t dt d d } tt	t}tt	| }z!| |
| W d   n1 s=w   Y  W |  |  n	|  |  w t td  }t dD ]}z| j|k rp|  W qb tyz   Y qbw td	| j | W S  ty   td
 Y dS w )u  Erstellt eine konsistente Kopie von tokens.db unter tokens_backups/.

    Nutzt die SQLite-Online-Backup-API (keine File-Locks nötig). Alte Backups
    (> BACKUP_KEEP_DAYS) werden entfernt. Gibt den Backup-Pfad zurück oder
    None, wenn die Quell-DB noch nicht existiert.
    Tr   Nz
tokens.db.z%Y-%m-%dz.bakr   ztokens.db.*.bakzBackup geschrieben: %szBackup fehlgeschlagen)
BACKUP_DIRr    r   r5   r   r@   strftimer!   r"   r#   backupclosetimeBACKUP_KEEP_DAYSglobstatst_mtimeunlinkOSErrorr:   r;   namer,   	exception)dstsrc_conndst_connr|   pr'   r'   r(   _backup_once  s>   


r>  c                   C  sF   t   	 z
tt t   W n ty!   td td Y nw q)NTzBackup-Schleife: Fehler<   )r>  r1  sleepBACKUP_INTERVAL_SECr,   r:   r9  r'   r'   r'   r(   _backup_loop8  s   


rB  c                  C  sh   t ( tdurt r	 W d   dS tjtddd} |   | aW d   dS 1 s-w   Y  dS )z.Startet den Backup-Thread idempotent (Daemon).Nzlicensing-backupT)targetr8  daemon)_BACKUP_THREAD_LOCKr+  is_alive	threadingThreadrB  start)tr'   r'   r(   start_backup_threadD  s   "rK  c                   C  s   t   t  dS )zHMuss einmal beim App-Start aufgerufen werden: DB-Schema + Backup-Thread.N)r=   rK  r'   r'   r'   r(   ensure_readyO  s   
rL  )r   r   )r   r0   )r   r#   )rF   rG   r   rH   )rL   rG   r   rM   )rL   r#   r   r#   )ra   r#   rb   r#   r   rM   )rt   r#   r   r0   )rt   r#   r   rG   rh   r#   r   r0   )
r   r#   r   r#   rt   r#   r   r#   r   r   rI   )r}   r   r   rG   r   r   )r   r#   r   r0   )r   r#   r   r   )T)r   rM   r   r   )NNr   )
r   r#   r   r   r   r   r   r#   r   r   )r   )r   r   r   r   )r   r   r   r   )r   r   r   r   )r   r#   r   r   rt   r#   r   r#   r   r#   r   r#   r   r   )r   r#   r   r#   r   r0   )rU   )r   r#   r   r#   rt   r#   r   rM   )NN)
r   r#   r  r#   r  rG   r  rG   r   r0   )Nr   )r   r#   r  rG   r  rG   r  r   r	  rG   r   r#   r   r#   )r   r#   r  rG   r   r#   )r   )
rL   r#   r  r   rh   r#   r   r#   r   r   )rL   r#   r   r#   r   r#   r   r0   )rL   r#   r   rG   )rL   r#   r   r#   r   r   )r   rM   r   r   )Nr   )rL   rG   r   r   r   r   )rL   r#   r   r   )rL   r#   r   r#   r   r   )r   r,  )brr   
__future__r   rc   r   loggingosrer\   shutilr!   rG  r1  r   
contextlibr   r   r   r   pathlibr   typingr   werkzeug.securityr	   r
   	getLoggerr:   __file__resolver   HERE_PROJECT_ROOT_DEFAULT_DATA_DIRenvirongetr    r#   r   r-  r4   r   r   ry   r{   r2  rA  compilerO   r^   localr   r)   r/   r=   rD   rK   rT   rV   r`   rf   r,   rg   r~   r   r   r   r   r   r   r   r   r   r   r   r   r   r  r   r
  r  r  r  r  r  r  r"  r#  r(  r)  r+  __annotations__LockrE  r>  rB  rK  rL  r'   r'   r'   r(   <module>   s    











	



	

	
\

	&



o

'

#
