2026-09-16 20:34:08 +02:00
|
|
|
"""Instance administration (SPEC §6.1).
|
|
|
|
|
|
2026-10-01 20:16:27 +02:00
|
|
|
The backend seam for the admin panel: the registration gate — the first account
|
|
|
|
|
on a fresh install becomes the administrator and closes signup behind itself,
|
|
|
|
|
and this is how that gets reopened — the default quota, and managing users
|
2026-10-25 15:39:33 +01:00
|
|
|
(search, per-user quota, block, delete). An administrator can change their own
|
|
|
|
|
quota but cannot block or delete their own account, so the instance can never
|
|
|
|
|
lock out its last admin that way.
|
2026-09-16 20:34:08 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-10-15 19:34:31 +02:00
|
|
|
import base64
|
2026-09-16 20:34:08 +02:00
|
|
|
import datetime as dt
|
2026-10-15 19:34:31 +02:00
|
|
|
import re
|
2026-10-01 20:16:27 +02:00
|
|
|
import uuid
|
2026-09-16 20:34:08 +02:00
|
|
|
from typing import Annotated
|
|
|
|
|
|
2026-10-18 22:04:04 +02:00
|
|
|
from fastapi import APIRouter, Depends, Path, Query
|
|
|
|
|
from pydantic import BaseModel
|
2026-10-01 20:16:27 +02:00
|
|
|
from sqlalchemy import func, select
|
2026-09-16 20:34:08 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-10-18 22:04:04 +02:00
|
|
|
from app import translation
|
2026-10-01 20:16:27 +02:00
|
|
|
from app.api.errors import raise_api_error
|
2026-10-15 19:34:31 +02:00
|
|
|
from app.config import get_settings
|
2026-09-16 20:34:08 +02:00
|
|
|
from app.db import get_session
|
2026-10-12 19:25:10 +02:00
|
|
|
from app.hosted.admin import MailServerError
|
2026-10-15 19:34:31 +02:00
|
|
|
from app.identity import invitations
|
2026-09-16 20:34:08 +02:00
|
|
|
from app.identity.deps import CurrentAdmin, require_csrf
|
2026-10-15 19:34:31 +02:00
|
|
|
from app.identity.mailer import (
|
|
|
|
|
LOGO_CID,
|
|
|
|
|
LOGO_DATA_URL,
|
|
|
|
|
LOGO_HTML,
|
|
|
|
|
MailerError,
|
|
|
|
|
mailer_config,
|
|
|
|
|
send_mail,
|
|
|
|
|
)
|
2026-10-01 20:16:27 +02:00
|
|
|
from app.identity.schemas import (
|
2026-10-15 19:34:31 +02:00
|
|
|
AdminInvitePreviewIn,
|
|
|
|
|
AdminInvitePreviewOut,
|
|
|
|
|
AdminInviteTemplateIn,
|
|
|
|
|
AdminInviteTemplateOut,
|
2026-10-01 20:16:27 +02:00
|
|
|
AdminMailIn,
|
|
|
|
|
AdminMailOut,
|
|
|
|
|
AdminMailTestIn,
|
|
|
|
|
AdminSettingsIn,
|
|
|
|
|
AdminSettingsOut,
|
|
|
|
|
AdminUserOut,
|
|
|
|
|
AdminUserPatch,
|
|
|
|
|
MessageOut,
|
|
|
|
|
)
|
2026-09-16 20:34:08 +02:00
|
|
|
from app.identity.service import app_settings
|
2026-10-01 20:16:27 +02:00
|
|
|
from app.identity.user_admin import delete_user, search_users, set_blocked
|
|
|
|
|
from app.models import Account, AppSettings, Message, User
|
|
|
|
|
from app.security.crypto import encrypt_secret
|
2026-09-16 20:34:08 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
|
|
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _out(row: AppSettings) -> AdminSettingsOut:
|
|
|
|
|
return AdminSettingsOut(
|
|
|
|
|
registration_open=row.registration_open,
|
|
|
|
|
default_quota_bytes=row.default_quota_bytes,
|
2026-10-15 19:34:31 +02:00
|
|
|
invitations_enabled=row.invitations_enabled,
|
2026-09-16 20:34:08 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/settings", response_model=AdminSettingsOut)
|
|
|
|
|
async def read_settings(admin: CurrentAdmin, db: DbSession) -> AdminSettingsOut:
|
|
|
|
|
return _out(await app_settings(db))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/settings", response_model=AdminSettingsOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def write_settings(
|
|
|
|
|
body: AdminSettingsIn, admin: CurrentAdmin, db: DbSession
|
|
|
|
|
) -> AdminSettingsOut:
|
|
|
|
|
"""Merge the supplied fields into the settings row.
|
|
|
|
|
|
|
|
|
|
Changing ``default_quota_bytes`` affects users created afterwards only —
|
|
|
|
|
nothing here rewrites an existing ``users.quota_bytes``.
|
|
|
|
|
"""
|
|
|
|
|
row = await app_settings(db, for_update=True)
|
|
|
|
|
if body.registration_open is not None:
|
|
|
|
|
row.registration_open = body.registration_open
|
|
|
|
|
if body.default_quota_bytes is not None:
|
|
|
|
|
row.default_quota_bytes = body.default_quota_bytes
|
2026-10-15 19:34:31 +02:00
|
|
|
if body.invitations_enabled is not None:
|
|
|
|
|
row.invitations_enabled = body.invitations_enabled
|
2026-09-16 20:34:08 +02:00
|
|
|
row.updated_at = dt.datetime.now(dt.UTC)
|
|
|
|
|
await db.commit()
|
|
|
|
|
return _out(row)
|
2026-10-01 20:16:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Outgoing system mail -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mail_out(row: AppSettings) -> AdminMailOut:
|
|
|
|
|
return AdminMailOut(
|
|
|
|
|
smtp_host=row.smtp_host,
|
|
|
|
|
smtp_port=row.smtp_port,
|
|
|
|
|
smtp_security=row.smtp_security,
|
|
|
|
|
smtp_username=row.smtp_username,
|
|
|
|
|
has_password=row.smtp_secret_ct is not None,
|
|
|
|
|
smtp_from=row.smtp_from,
|
|
|
|
|
public_url=row.public_url,
|
|
|
|
|
configured=bool(row.smtp_host and row.smtp_from and row.public_url),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/mail", response_model=AdminMailOut)
|
|
|
|
|
async def read_mail(admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
|
|
|
|
return _mail_out(await app_settings(db))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/mail", response_model=AdminMailOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def write_mail(body: AdminMailIn, admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
|
|
|
|
"""Merge the supplied SMTP fields. The password is stored vault-encrypted."""
|
|
|
|
|
row = await app_settings(db, for_update=True)
|
|
|
|
|
for name in ("smtp_host", "smtp_username", "smtp_from"):
|
|
|
|
|
value = getattr(body, name)
|
|
|
|
|
if value is not None:
|
|
|
|
|
setattr(row, name, value.strip())
|
|
|
|
|
if body.smtp_port is not None:
|
|
|
|
|
row.smtp_port = body.smtp_port
|
|
|
|
|
if body.smtp_security is not None:
|
|
|
|
|
row.smtp_security = body.smtp_security
|
|
|
|
|
if body.smtp_password is not None:
|
|
|
|
|
row.smtp_secret_ct = (
|
|
|
|
|
encrypt_secret(body.smtp_password.encode()) if body.smtp_password else None
|
|
|
|
|
)
|
|
|
|
|
if body.public_url is not None:
|
|
|
|
|
url = body.public_url.strip().rstrip("/")
|
|
|
|
|
if url and not url.startswith(("http://", "https://")):
|
|
|
|
|
raise_api_error(400, "invalid_url", "the address must start with http:// or https://")
|
|
|
|
|
row.public_url = url
|
|
|
|
|
row.updated_at = dt.datetime.now(dt.UTC)
|
|
|
|
|
await db.commit()
|
|
|
|
|
return _mail_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/mail/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
|
|
|
|
|
"""Send a test message with the saved settings, reporting the server's error."""
|
|
|
|
|
cfg = mailer_config(await app_settings(db))
|
|
|
|
|
if cfg is None:
|
|
|
|
|
raise_api_error(
|
|
|
|
|
409, "mail_not_configured", "set the SMTP server, From address and public address first"
|
|
|
|
|
)
|
|
|
|
|
text = (
|
|
|
|
|
"This is a test message from Ultimo Email.\n\n"
|
|
|
|
|
"Outgoing system mail works: password-reset links will be sent from this account.\n"
|
|
|
|
|
)
|
2026-10-01 21:02:44 +02:00
|
|
|
# Same logo header as the reset mail, so the test shows what users will get.
|
|
|
|
|
html = LOGO_HTML + f"<p>{text.replace(chr(10) * 2, '</p><p>').strip()}</p>"
|
2026-10-01 20:16:27 +02:00
|
|
|
try:
|
|
|
|
|
await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html)
|
|
|
|
|
except MailerError as exc:
|
|
|
|
|
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
|
|
|
|
|
return MessageOut()
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 19:34:31 +02:00
|
|
|
# --- Invitation email ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+")
|
|
|
|
|
_SAMPLE = {"invitee_name": "Anna Nowak", "inviter_name": "Jan Kowalski"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _template_out(row: AppSettings) -> AdminInviteTemplateOut:
|
|
|
|
|
return AdminInviteTemplateOut(
|
|
|
|
|
subject=row.invite_subject,
|
|
|
|
|
html=row.invite_template,
|
|
|
|
|
from_address=row.invite_from,
|
|
|
|
|
default_subject=invitations.DEFAULT_SUBJECT,
|
|
|
|
|
default_html=invitations.DEFAULT_TEMPLATE,
|
|
|
|
|
default_from=row.smtp_from,
|
|
|
|
|
placeholders=list(invitations.PLACEHOLDERS),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/invitations/template", response_model=AdminInviteTemplateOut)
|
|
|
|
|
async def read_invite_template(admin: CurrentAdmin, db: DbSession) -> AdminInviteTemplateOut:
|
|
|
|
|
return _template_out(await app_settings(db))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put(
|
|
|
|
|
"/invitations/template",
|
|
|
|
|
response_model=AdminInviteTemplateOut,
|
|
|
|
|
dependencies=[Depends(require_csrf)],
|
|
|
|
|
)
|
|
|
|
|
async def write_invite_template(
|
|
|
|
|
body: AdminInviteTemplateIn, admin: CurrentAdmin, db: DbSession
|
|
|
|
|
) -> AdminInviteTemplateOut:
|
|
|
|
|
"""Upload the invitation email's HTML template, subject and sender."""
|
|
|
|
|
row = await app_settings(db, for_update=True)
|
|
|
|
|
if body.html is not None:
|
|
|
|
|
html = body.html.strip()
|
|
|
|
|
if html and (problem := invitations.template_problem(html)):
|
|
|
|
|
raise_api_error(400, "invalid_template", problem)
|
|
|
|
|
row.invite_template = html
|
|
|
|
|
if body.subject is not None:
|
|
|
|
|
row.invite_subject = " ".join(body.subject.split())
|
|
|
|
|
if body.from_address is not None:
|
|
|
|
|
sender = body.from_address.strip()
|
|
|
|
|
if sender and not _EMAIL_RE.fullmatch(sender):
|
|
|
|
|
raise_api_error(400, "invalid_email", "the sender must be an email address")
|
|
|
|
|
row.invite_from = sender
|
|
|
|
|
row.updated_at = dt.datetime.now(dt.UTC)
|
|
|
|
|
await db.commit()
|
|
|
|
|
return _template_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sample(row: AppSettings, subject: str, html: str) -> invitations.RenderedMail:
|
|
|
|
|
"""The invitation as a friend would get it, with sample names."""
|
|
|
|
|
if html.strip() and (problem := invitations.template_problem(html)):
|
|
|
|
|
raise_api_error(400, "invalid_template", problem)
|
|
|
|
|
draft = AppSettings(invite_subject=subject, invite_template=html.strip())
|
|
|
|
|
base = row.public_url.rstrip("/") or f"https://{get_settings().app_domain}"
|
|
|
|
|
return invitations.render(draft, link=f"{base}/register#invite=sample", **_SAMPLE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/invitations/preview",
|
|
|
|
|
response_model=AdminInvitePreviewOut,
|
|
|
|
|
dependencies=[Depends(require_csrf)],
|
|
|
|
|
)
|
|
|
|
|
async def preview_invite(
|
|
|
|
|
body: AdminInvitePreviewIn, admin: CurrentAdmin, db: DbSession
|
|
|
|
|
) -> AdminInvitePreviewOut:
|
|
|
|
|
"""Render a template (saved or not) with sample names, for the panel to show."""
|
|
|
|
|
mail = _sample(await app_settings(db), body.subject, body.html)
|
|
|
|
|
html = mail.html
|
|
|
|
|
# The preview frame has no message parts: show the images it would carry.
|
|
|
|
|
for part in mail.inline:
|
|
|
|
|
data = base64.b64encode(part.data).decode()
|
|
|
|
|
html = html.replace(f"cid:{part.cid}", f"data:{part.mime_type};base64,{data}")
|
|
|
|
|
html = html.replace(f"cid:{LOGO_CID}", LOGO_DATA_URL)
|
|
|
|
|
return AdminInvitePreviewOut(subject=mail.subject, html=html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/invitations/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def test_invite(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
|
|
|
|
|
"""Send the saved invitation email, with sample names and a dummy link."""
|
|
|
|
|
row = await app_settings(db)
|
|
|
|
|
cfg = mailer_config(row)
|
|
|
|
|
if cfg is None:
|
|
|
|
|
raise_api_error(
|
|
|
|
|
409, "mail_not_configured", "set the SMTP server, From address and public address first"
|
|
|
|
|
)
|
|
|
|
|
mail = _sample(row, row.invite_subject, row.invite_template)
|
|
|
|
|
try:
|
|
|
|
|
await send_mail(
|
|
|
|
|
cfg,
|
|
|
|
|
body.to.strip(),
|
|
|
|
|
mail.subject,
|
|
|
|
|
mail.text,
|
|
|
|
|
mail.html,
|
|
|
|
|
sender=invitations.sender_address(row),
|
|
|
|
|
inline=mail.inline,
|
|
|
|
|
)
|
|
|
|
|
except MailerError as exc:
|
|
|
|
|
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
|
|
|
|
|
return MessageOut()
|
|
|
|
|
|
|
|
|
|
|
2026-10-01 20:16:27 +02:00
|
|
|
# --- Users --------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut:
|
|
|
|
|
return AdminUserOut(
|
|
|
|
|
id=user.id,
|
|
|
|
|
handle=user.handle,
|
|
|
|
|
is_admin=user.is_admin,
|
|
|
|
|
blocked_at=user.blocked_at.isoformat() if user.blocked_at else None,
|
|
|
|
|
quota_bytes=user.quota_bytes,
|
|
|
|
|
used_bytes=used_bytes,
|
|
|
|
|
created_at=user.created_at.isoformat(),
|
|
|
|
|
accounts=accounts,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-25 15:39:33 +01:00
|
|
|
async def _target(
|
|
|
|
|
db: AsyncSession, admin: User, user_id: uuid.UUID, *, allow_self: bool = False
|
|
|
|
|
) -> User:
|
|
|
|
|
"""The user an admin action is aimed at. Blocking and deleting are never
|
|
|
|
|
allowed on the admin themselves; a quota change is (``allow_self``)."""
|
2026-10-01 20:16:27 +02:00
|
|
|
user = await db.scalar(select(User).where(User.id == user_id))
|
|
|
|
|
if user is None:
|
|
|
|
|
raise_api_error(404, "not_found", "user not found")
|
2026-10-25 15:39:33 +01:00
|
|
|
if user.id == admin.id and not allow_self:
|
2026-10-01 20:16:27 +02:00
|
|
|
raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account")
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/users", response_model=list[AdminUserOut])
|
|
|
|
|
async def list_users(
|
|
|
|
|
admin: CurrentAdmin, db: DbSession, q: str = Query("", max_length=200)
|
|
|
|
|
) -> list[AdminUserOut]:
|
|
|
|
|
"""Search users by login, or by a mail account's address or display name."""
|
|
|
|
|
return [_user_out(u, used, emails) for u, used, emails in await search_users(db, q)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=AdminUserOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def patch_user(
|
|
|
|
|
user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession
|
|
|
|
|
) -> AdminUserOut:
|
|
|
|
|
"""Change a user's quota and/or block or unblock them."""
|
2026-10-25 15:39:33 +01:00
|
|
|
# Your own quota is fine to change; blocking yourself is not.
|
|
|
|
|
user = await _target(db, admin, user_id, allow_self=body.blocked is None)
|
2026-10-01 20:16:27 +02:00
|
|
|
if body.quota_bytes is not None:
|
|
|
|
|
user.quota_bytes = body.quota_bytes
|
|
|
|
|
if body.blocked is not None:
|
2026-10-12 19:25:10 +02:00
|
|
|
try:
|
|
|
|
|
await set_blocked(db, user, body.blocked)
|
|
|
|
|
except MailServerError as exc:
|
|
|
|
|
raise_api_error(
|
|
|
|
|
502, "mail_server_unavailable", f"the mail server did not answer: {exc}"
|
|
|
|
|
)
|
2026-10-01 20:16:27 +02:00
|
|
|
await db.commit()
|
|
|
|
|
used = await db.scalar(
|
|
|
|
|
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
|
|
|
|
|
)
|
|
|
|
|
emails = list(
|
|
|
|
|
await db.scalars(
|
|
|
|
|
select(Account.email).where(Account.user_id == user.id).order_by(Account.email)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return _user_out(user, int(used or 0), emails)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/users/{user_id}", dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
|
|
|
|
|
"""Delete a user and all of their data. Cannot be undone."""
|
|
|
|
|
user = await _target(db, admin, user_id)
|
2026-10-12 19:25:10 +02:00
|
|
|
try:
|
|
|
|
|
await delete_user(db, user)
|
|
|
|
|
except MailServerError as exc:
|
|
|
|
|
raise_api_error(502, "mail_server_unavailable", f"the mail server did not answer: {exc}")
|
2026-10-01 20:16:27 +02:00
|
|
|
return {"status": "deleted"}
|
2026-10-18 22:04:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- email translation languages (translator service) ------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InstallProgressOut(BaseModel):
|
|
|
|
|
done: int
|
|
|
|
|
total: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminTranslationOut(BaseModel):
|
|
|
|
|
enabled: bool # TRANSLATOR_URL is set
|
|
|
|
|
reachable: bool
|
|
|
|
|
installed: list[str] = []
|
|
|
|
|
installing: dict[str, InstallProgressOut] = {}
|
2026-10-18 22:21:38 +02:00
|
|
|
pending: list[str] = [] # waiting to be tried again (no network last time)
|
2026-10-18 22:04:04 +02:00
|
|
|
available: list[str] = [] # what Mozilla has models for (empty: offline)
|
|
|
|
|
errors: dict[str, str] = {} # why a language's last install failed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_LANG = Path(pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/translation", response_model=AdminTranslationOut)
|
|
|
|
|
async def translation_status(admin: CurrentAdmin) -> AdminTranslationOut:
|
|
|
|
|
if not translation.enabled():
|
|
|
|
|
return AdminTranslationOut(enabled=False, reachable=False)
|
|
|
|
|
try:
|
|
|
|
|
data = await translation.status()
|
|
|
|
|
except translation.TranslationError:
|
|
|
|
|
return AdminTranslationOut(enabled=True, reachable=False)
|
|
|
|
|
return AdminTranslationOut(enabled=True, reachable=True, **data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/translation/languages/{code}",
|
|
|
|
|
response_model=AdminTranslationOut,
|
|
|
|
|
dependencies=[Depends(require_csrf)],
|
|
|
|
|
)
|
|
|
|
|
async def install_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
|
|
|
|
|
"""Download a language's models (to and from English) in the background."""
|
|
|
|
|
try:
|
|
|
|
|
await translation.install(code)
|
|
|
|
|
except translation.TranslationError as exc:
|
|
|
|
|
raise_api_error(502, "translator_failed", str(exc))
|
|
|
|
|
return await translation_status(admin)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/translation/languages/{code}",
|
|
|
|
|
response_model=AdminTranslationOut,
|
|
|
|
|
dependencies=[Depends(require_csrf)],
|
|
|
|
|
)
|
|
|
|
|
async def remove_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
|
|
|
|
|
try:
|
|
|
|
|
await translation.remove(code)
|
|
|
|
|
except translation.TranslationError as exc:
|
|
|
|
|
raise_api_error(502, "translator_failed", str(exc))
|
|
|
|
|
return await translation_status(admin)
|