Revert "feat(sources): X-Account-Verwaltung im Verwaltungsportal"
This reverts commit bd476edb13.
Dieser Commit ist enthalten in:
@@ -42,7 +42,7 @@ router = APIRouter(prefix="/api/sources", tags=["sources"])
|
|||||||
|
|
||||||
SOURCE_UPDATE_COLUMNS = {
|
SOURCE_UPDATE_COLUMNS = {
|
||||||
"name", "url", "domain", "source_type", "category", "status", "notes",
|
"name", "url", "domain", "source_type", "category", "status", "notes",
|
||||||
"language", "primary_language", "bias", "fetch_strategy",
|
"language", "bias", "fetch_strategy",
|
||||||
"political_orientation", "media_type", "reliability",
|
"political_orientation", "media_type", "reliability",
|
||||||
"state_affiliated", "country_code",
|
"state_affiliated", "country_code",
|
||||||
}
|
}
|
||||||
@@ -118,12 +118,11 @@ class GlobalSourceCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
domain: Optional[str] = None
|
domain: Optional[str] = None
|
||||||
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$")
|
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
|
||||||
category: str = Field(default="sonstige")
|
category: str = Field(default="sonstige")
|
||||||
status: str = Field(default="active", pattern="^(active|inactive)$")
|
status: str = Field(default="active", pattern="^(active|inactive)$")
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
language: Optional[str] = Field(default=None, max_length=100)
|
language: Optional[str] = Field(default=None, max_length=100)
|
||||||
primary_language: Optional[str] = Field(default=None, max_length=16)
|
|
||||||
bias: Optional[str] = Field(default=None, max_length=500)
|
bias: Optional[str] = Field(default=None, max_length=500)
|
||||||
fetch_strategy: Optional[str] = Field(default="default", pattern="^(default|googlebot|paywall|skip)$")
|
fetch_strategy: Optional[str] = Field(default="default", pattern="^(default|googlebot|paywall|skip)$")
|
||||||
|
|
||||||
@@ -132,12 +131,11 @@ class GlobalSourceUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(default=None, max_length=200)
|
name: Optional[str] = Field(default=None, max_length=200)
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
domain: Optional[str] = None
|
domain: Optional[str] = None
|
||||||
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$")
|
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
|
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
language: Optional[str] = Field(default=None, max_length=100)
|
language: Optional[str] = Field(default=None, max_length=100)
|
||||||
primary_language: Optional[str] = Field(default=None, max_length=16)
|
|
||||||
bias: Optional[str] = Field(default=None, max_length=500)
|
bias: Optional[str] = Field(default=None, max_length=500)
|
||||||
political_orientation: Optional[str] = None
|
political_orientation: Optional[str] = None
|
||||||
media_type: Optional[str] = None
|
media_type: Optional[str] = None
|
||||||
@@ -232,10 +230,10 @@ async def create_global_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, primary_language, bias, fetch_strategy, added_by, tenant_id)
|
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, bias, fetch_strategy, added_by, tenant_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
||||||
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes,
|
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes,
|
||||||
data.language, data.primary_language, data.bias, data.fetch_strategy or "default"),
|
data.language, data.bias, data.fetch_strategy or "default"),
|
||||||
)
|
)
|
||||||
src_id = cursor.lastrowid
|
src_id = cursor.lastrowid
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ SOURCE_CATEGORIES: list[CategoryEntry] = [
|
|||||||
{"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"},
|
{"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"},
|
||||||
{"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"},
|
{"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"},
|
||||||
{"key": "syrien-nahost", "label": "Syrien / Nahost"},
|
{"key": "syrien-nahost", "label": "Syrien / Nahost"},
|
||||||
{"key": "x", "label": "X-Recherche"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ SOURCE_TYPES: list[TypeEntry] = [
|
|||||||
{"key": "telegram_channel", "label": "Telegram-Kanal"},
|
{"key": "telegram_channel", "label": "Telegram-Kanal"},
|
||||||
{"key": "podcast_feed", "label": "Podcast-Feed"},
|
{"key": "podcast_feed", "label": "Podcast-Feed"},
|
||||||
{"key": "excluded", "label": "Ausgeschlossen"},
|
{"key": "excluded", "label": "Ausgeschlossen"},
|
||||||
{"key": "x_account", "label": "X-Account"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,6 @@
|
|||||||
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
|
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
|
||||||
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
|
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
|
||||||
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
|
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
|
||||||
<button class="nav-tab" data-subtab="x-accounts">X-Accounts</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grundquellen -->
|
<!-- Grundquellen -->
|
||||||
@@ -472,36 +471,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- X-Accounts (Sub-Tab) -->
|
|
||||||
<div class="section" id="sub-x-accounts">
|
|
||||||
<div class="action-bar">
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
|
||||||
<input type="text" class="search-input" id="xAccountSearch" placeholder="X-Account suchen..." oninput="filterXAccounts()">
|
|
||||||
<span class="text-secondary" id="xAccountCount"></span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" id="newXAccountBtn" onclick="openXAccountModal()">+ X-Account hinzufügen</button>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<p class="text-secondary" style="padding:0 4px 12px;">X-Accounts (Twitter), die der Monitor als Recherchequelle nutzt. Pro Lage über die Option „X (Twitter) einbeziehen" zuschaltbar.</p>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>URL</th>
|
|
||||||
<th>Sprache</th>
|
|
||||||
<th>Notiz</th>
|
|
||||||
<th>Artikel</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="xAccountTable"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div> <!-- /sec-sources -->
|
</div> <!-- /sec-sources -->
|
||||||
|
|
||||||
<!-- Audit-Log Section -->
|
<!-- Audit-Log Section -->
|
||||||
@@ -969,59 +938,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal: X-Account -->
|
|
||||||
<div class="modal-overlay" id="modalXAccount">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 id="xAccountModalTitle">X-Account hinzufügen</h3>
|
|
||||||
<button class="modal-close" onclick="closeModal('modalXAccount')">×</button>
|
|
||||||
</div>
|
|
||||||
<form id="xAccountForm">
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="xAccountHandle">X-Handle oder URL</label>
|
|
||||||
<input type="text" id="xAccountHandle" required placeholder="z.B. bellingcat oder https://x.com/bellingcat">
|
|
||||||
<small class="text-secondary">Account-Name mit oder ohne @, oder die volle x.com-URL.</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="xAccountName">Anzeigename</label>
|
|
||||||
<input type="text" id="xAccountName" placeholder="optional, z.B. Bellingcat">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="xAccountLanguage">Sprache</label>
|
|
||||||
<select id="xAccountLanguage">
|
|
||||||
<option value="en">Englisch</option>
|
|
||||||
<option value="de">Deutsch</option>
|
|
||||||
<option value="fr">Französisch</option>
|
|
||||||
<option value="ru">Russisch</option>
|
|
||||||
<option value="uk">Ukrainisch</option>
|
|
||||||
<option value="">Unbestimmt</option>
|
|
||||||
</select>
|
|
||||||
<small class="text-secondary">Steuert das Keyword-Matching im Monitor.</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="xAccountNotes">Notiz</label>
|
|
||||||
<textarea id="xAccountNotes" rows="2" placeholder="Kurzbeschreibung, hilft der KI bei der Account-Auswahl pro Lage"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="xAccountStatus">Status</label>
|
|
||||||
<select id="xAccountStatus">
|
|
||||||
<option value="active">Aktiv</option>
|
|
||||||
<option value="inactive">Inaktiv</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="xAccountError" class="error-msg" style="display:none"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeModal('modalXAccount')">Abbrechen</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/js/app.js?v=20260522a"></script>
|
<script src="/static/js/app.js?v=20260522a"></script>
|
||||||
<script src="/static/js/sources.js?v=20260522x"></script>
|
<script src="/static/js/sources.js?v=20260509d"></script>
|
||||||
<script src="/static/js/source-health.js?v=20260509l"></script>
|
<script src="/static/js/source-health.js?v=20260509l"></script>
|
||||||
<script src="/static/js/audit.js?v=20260509d"></script>
|
<script src="/static/js/audit.js?v=20260509d"></script>
|
||||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|||||||
@@ -38,152 +38,10 @@ function setupSourceSubTabs() {
|
|||||||
else if (subtab === "tenant-sources") loadTenantSources();
|
else if (subtab === "tenant-sources") loadTenantSources();
|
||||||
else if (subtab === "source-health") loadHealthData();
|
else if (subtab === "source-health") loadHealthData();
|
||||||
else if (subtab === "classification-review") loadClassificationQueue();
|
else if (subtab === "classification-review") loadClassificationQueue();
|
||||||
else if (subtab === "x-accounts") loadXAccounts();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- X-Accounts (Recherche-Accounts für den Monitor) ---
|
|
||||||
let xAccountsCache = [];
|
|
||||||
let editingXAccountId = null;
|
|
||||||
|
|
||||||
function normalizeXHandle(raw) {
|
|
||||||
let h = (raw || "").trim();
|
|
||||||
h = h.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
|
|
||||||
h = h.replace(/^(x\.com\/|twitter\.com\/|nitter\.net\/)/i, "");
|
|
||||||
h = h.replace(/^@/, "").replace(/\/+$/, "");
|
|
||||||
return h.split(/[/?#]/)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadXAccounts() {
|
|
||||||
setupXAccountForm();
|
|
||||||
const tbody = document.getElementById("xAccountTable");
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Lade...</td></tr>';
|
|
||||||
try {
|
|
||||||
const all = await API.get("/api/sources/global");
|
|
||||||
xAccountsCache = (all || []).filter((s) => s.source_type === "x_account");
|
|
||||||
renderXAccounts(xAccountsCache);
|
|
||||||
} catch (err) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Fehler beim Laden</td></tr>';
|
|
||||||
showToast("X-Accounts konnten nicht geladen werden", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderXAccounts(list) {
|
|
||||||
const tbody = document.getElementById("xAccountTable");
|
|
||||||
const cnt = document.getElementById("xAccountCount");
|
|
||||||
if (cnt) cnt.textContent = list.length + (list.length === 1 ? " Account" : " Accounts");
|
|
||||||
if (!list.length) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine X-Accounts. Mit „+ X-Account hinzufügen" anlegen.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tbody.innerHTML = list.map((s) => {
|
|
||||||
const handle = normalizeXHandle(s.url || s.domain || s.name || "");
|
|
||||||
const url = "https://x.com/" + handle;
|
|
||||||
const lang = s.primary_language || s.language || "—";
|
|
||||||
const notes = s.notes ? esc(s.notes) : '<span class="text-muted">—</span>';
|
|
||||||
const status = s.status === "active"
|
|
||||||
? '<span style="color:var(--success,#2e7d32);">Aktiv</span>'
|
|
||||||
: '<span class="text-muted">Inaktiv</span>';
|
|
||||||
return '<tr>'
|
|
||||||
+ '<td><strong>' + esc(s.name || ("@" + handle)) + '</strong></td>'
|
|
||||||
+ '<td><a href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(handle) + '</a></td>'
|
|
||||||
+ '<td>' + esc(lang) + '</td>'
|
|
||||||
+ '<td>' + notes + '</td>'
|
|
||||||
+ '<td>' + (s.article_count || 0) + '</td>'
|
|
||||||
+ '<td>' + status + '</td>'
|
|
||||||
+ '<td>'
|
|
||||||
+ '<button class="btn btn-secondary btn-small" onclick="openXAccountModal(' + s.id + ')">Bearbeiten</button> '
|
|
||||||
+ '<button class="btn btn-danger btn-small" onclick="confirmDeleteXAccount(' + s.id + ')">Löschen</button>'
|
|
||||||
+ '</td>'
|
|
||||||
+ '</tr>';
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterXAccounts() {
|
|
||||||
const q = (document.getElementById("xAccountSearch").value || "").toLowerCase();
|
|
||||||
if (!q) { renderXAccounts(xAccountsCache); return; }
|
|
||||||
renderXAccounts(xAccountsCache.filter((s) =>
|
|
||||||
(s.name || "").toLowerCase().includes(q)
|
|
||||||
|| (s.url || "").toLowerCase().includes(q)
|
|
||||||
|| (s.notes || "").toLowerCase().includes(q)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function openXAccountModal(id) {
|
|
||||||
editingXAccountId = id || null;
|
|
||||||
const errEl = document.getElementById("xAccountError");
|
|
||||||
errEl.style.display = "none";
|
|
||||||
const s = editingXAccountId ? xAccountsCache.find((a) => a.id === editingXAccountId) : null;
|
|
||||||
if (editingXAccountId && !s) return;
|
|
||||||
document.getElementById("xAccountModalTitle").textContent = s ? "X-Account bearbeiten" : "X-Account hinzufügen";
|
|
||||||
document.getElementById("xAccountHandle").value = s ? normalizeXHandle(s.url || s.domain || "") : "";
|
|
||||||
document.getElementById("xAccountName").value = s ? (s.name || "") : "";
|
|
||||||
document.getElementById("xAccountLanguage").value = s ? (s.primary_language || s.language || "en") : "en";
|
|
||||||
document.getElementById("xAccountNotes").value = s ? (s.notes || "") : "";
|
|
||||||
document.getElementById("xAccountStatus").value = s ? (s.status || "active") : "active";
|
|
||||||
openModal("modalXAccount");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupXAccountForm() {
|
|
||||||
const form = document.getElementById("xAccountForm");
|
|
||||||
if (!form || form.dataset.wired) return;
|
|
||||||
form.dataset.wired = "1";
|
|
||||||
form.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const errEl = document.getElementById("xAccountError");
|
|
||||||
errEl.style.display = "none";
|
|
||||||
const handle = normalizeXHandle(document.getElementById("xAccountHandle").value);
|
|
||||||
if (!handle) {
|
|
||||||
errEl.textContent = "Bitte einen Handle oder eine x.com-URL eingeben.";
|
|
||||||
errEl.style.display = "block";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nameVal = document.getElementById("xAccountName").value.trim();
|
|
||||||
const body = {
|
|
||||||
name: nameVal || ("@" + handle),
|
|
||||||
url: "x.com/" + handle,
|
|
||||||
domain: "x.com/" + handle,
|
|
||||||
source_type: "x_account",
|
|
||||||
category: "x",
|
|
||||||
status: document.getElementById("xAccountStatus").value,
|
|
||||||
notes: document.getElementById("xAccountNotes").value.trim() || null,
|
|
||||||
primary_language: document.getElementById("xAccountLanguage").value || null,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
if (editingXAccountId) {
|
|
||||||
await API.put("/api/sources/global/" + editingXAccountId, body);
|
|
||||||
} else {
|
|
||||||
await API.post("/api/sources/global", body);
|
|
||||||
}
|
|
||||||
closeModal("modalXAccount");
|
|
||||||
loadXAccounts();
|
|
||||||
showToast("X-Account gespeichert.", "success");
|
|
||||||
} catch (err) {
|
|
||||||
errEl.textContent = err.message || "Speichern fehlgeschlagen";
|
|
||||||
errEl.style.display = "block";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeleteXAccount(id) {
|
|
||||||
const s = xAccountsCache.find((a) => a.id === id);
|
|
||||||
if (!s) return;
|
|
||||||
showConfirm(
|
|
||||||
"X-Account entfernen",
|
|
||||||
'Soll der X-Account "' + (s.name || "") + '" als Recherchequelle entfernt werden?',
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
await API.del("/api/sources/global/" + id);
|
|
||||||
loadXAccounts();
|
|
||||||
showToast("X-Account entfernt.", "success");
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "Löschen fehlgeschlagen", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Grundquellen ---
|
// --- Grundquellen ---
|
||||||
async function loadGlobalSources() {
|
async function loadGlobalSources() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren