feat: v4.2.0 - series blocks (add to all days, delete one/all in series); 37 tests
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
This commit is contained in:
@@ -10,12 +10,14 @@ Webový nástroj pro tvorbu časových scénářů zážitkových kurzů a výje
|
||||
|
||||
- **Grafický editor** — bloky na časové ose, přetahování myší, změna délky tažením pravého okraje, snap na 15 minut
|
||||
- **Vícedenní scénář** — nastavíš rozsah Od/Do, každý den = jeden řádek
|
||||
- **Série bloků** — checkbox „Přidat do každého dne kurzu" vytvoří identický blok pro všechny dny najednou; při smazání lze smazat jen jeden blok nebo celou sérii
|
||||
- **JSON import/export** — uložíš scénář, kdykoli ho znovu načteš
|
||||
- **Vzorový JSON** — `GET /api/sample`
|
||||
- **PDF výstup** — A4 na šířku, vždy 1 stránka, barevné bloky dle typů, legenda
|
||||
- Garant viditelný přímo v bloku
|
||||
- Bloky s poznámkou mají horní index (¹ ² ³...)
|
||||
- Stránka 2 (pokud jsou poznámky): výpis všech poznámek ke scénáři
|
||||
- **České dny** — v editoru i PDF formát „Pondělí (20.2.)", LiberationSans font pro správnou diakritiku
|
||||
- **Dokumentace na webu** — záložka "Dokumentace" přímo v aplikaci
|
||||
- **Swagger UI** — `GET /docs`
|
||||
|
||||
@@ -60,7 +62,7 @@ open http://localhost:8080
|
||||
python3 -m pytest tests/ -v
|
||||
```
|
||||
|
||||
35 testů pokrývá API endpointy, PDF generátor a validaci dat.
|
||||
37 testů pokrývá API endpointy, PDF generátor, validaci dat, overnight bloky a series_id.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,7 +120,8 @@ Kubernetes manifest: `sukany-org/rke2-deployments` → `scenar/scenar.yaml`
|
||||
"title": "Název bloku",
|
||||
"type_id": "main",
|
||||
"responsible": "Garant (volitelné)",
|
||||
"notes": "Poznámka → horní index v PDF (volitelné)"
|
||||
"notes": "Poznámka → horní index v PDF (volitelné)",
|
||||
"series_id": "ID sdílené série (volitelné, generováno automaticky)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class Block(BaseModel):
|
||||
type_id: str
|
||||
responsible: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
series_id: Optional[str] = None # shared across blocks added via "add to all days"
|
||||
|
||||
|
||||
class ProgramType(BaseModel):
|
||||
|
||||
@@ -946,3 +946,61 @@ body {
|
||||
margin-bottom: 4px;
|
||||
color: var(--header-bg);
|
||||
}
|
||||
|
||||
/* Modal footer with left/right split */
|
||||
.modal-footer-left {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Outline danger button (for "Smazat sérii") */
|
||||
.btn-danger-outline {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger-outline:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Series checkbox row */
|
||||
.series-row {
|
||||
margin-top: 4px;
|
||||
padding: 10px 12px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.series-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.series-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.series-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-light);
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="header-title">Scenár Creator</h1>
|
||||
<span class="header-version">v4.0</span>
|
||||
<span class="header-version">v4.2</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="tab-content hidden" id="tab-docs">
|
||||
<div class="docs-container">
|
||||
<h2>Scenár Creator — Dokumentace</h2>
|
||||
<p class="docs-version">Verze 4.2 | <a href="/docs" target="_blank">Swagger API</a> | <a href="/api/sample">Vzorový JSON</a></p>
|
||||
<p class="docs-version">Verze 4.2.0 | <a href="/docs" target="_blank">Swagger API</a> | <a href="/api/sample">Vzorový JSON</a></p>
|
||||
|
||||
<h3>Jak začít</h3>
|
||||
<ol>
|
||||
@@ -103,10 +103,12 @@
|
||||
<h3>Práce s bloky</h3>
|
||||
<ul>
|
||||
<li><strong>Přidání:</strong> Klikněte na „+ Přidat blok" nebo klikněte na prázdné místo v řádku dne.</li>
|
||||
<li><strong>Přidání do všech dnů:</strong> V modalu nového bloku zaškrtněte „Přidat do každého dne kurzu" — vytvoří identický blok pro každý den akce (sdílená série).</li>
|
||||
<li><strong>Přesun:</strong> Chytněte blok a táhněte doleva/doprava. Snap na 15 minut.</li>
|
||||
<li><strong>Změna délky:</strong> Chytněte pravý okraj bloku a táhněte.</li>
|
||||
<li><strong>Úprava:</strong> Klikněte na blok — otevře se formulář.</li>
|
||||
<li><strong>Smazání:</strong> V editačním formuláři klikněte na „Smazat blok".</li>
|
||||
<li><strong>Úprava:</strong> Klikněte na blok — otevře se formulář s editací jednoho bloku.</li>
|
||||
<li><strong>Smazání jednoho bloku:</strong> V editačním formuláři klikněte na „Smazat jen tento".</li>
|
||||
<li><strong>Smazání celé série:</strong> Pokud byl blok přidán jako součást série (zaškrtávací políčko), zobrazí se tlačítko „Smazat sérii" — smaže všechny bloky se stejným series_id.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Formulář bloku</h3>
|
||||
@@ -151,6 +153,7 @@
|
||||
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu (musí existovat v program_types)</td></tr>
|
||||
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant — zobrazí se v editoru i PDF</td></tr>
|
||||
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka — jen v PDF, jako horní index + stránka 2</td></tr>
|
||||
<tr><td>blocks[].series_id</td><td>string?</td><td>Sdílené ID série — bloky přidané přes „Přidat do všech dnů" sdílejí toto ID</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -216,9 +219,20 @@
|
||||
<label>Poznámka</label>
|
||||
<textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea>
|
||||
</div>
|
||||
<!-- Shown only when creating a new block -->
|
||||
<div class="form-group series-row hidden" id="seriesRow">
|
||||
<label class="series-label">
|
||||
<input type="checkbox" id="modalAddToAllDays">
|
||||
Přidat do každého dne kurzu
|
||||
</label>
|
||||
<p class="series-hint">Vytvoří identický blok pro každý den akce (sdílená série).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-danger btn-sm" id="modalDeleteBtn">Smazat blok</button>
|
||||
<div class="modal-footer-left">
|
||||
<button class="btn btn-danger btn-sm hidden" id="modalDeleteBtn">Smazat jen tento</button>
|
||||
<button class="btn btn-danger-outline btn-sm hidden" id="modalDeleteSeriesBtn">Smazat sérii</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="modalSaveBtn">Uložit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,7 +178,18 @@ const App = {
|
||||
this._populateDaySelect(block.date);
|
||||
this._updateDuration();
|
||||
|
||||
document.getElementById('modalDeleteBtn').style.display = 'inline-block';
|
||||
// Show delete buttons; series delete only if block belongs to a series
|
||||
document.getElementById('modalDeleteBtn').classList.remove('hidden');
|
||||
document.getElementById('seriesRow').classList.add('hidden');
|
||||
const seriesBtn = document.getElementById('modalDeleteSeriesBtn');
|
||||
if (block.series_id) {
|
||||
const seriesCount = this.state.blocks.filter(b => b.series_id === block.series_id).length;
|
||||
seriesBtn.textContent = `Smazat sérii (${seriesCount} bloků)`;
|
||||
seriesBtn.classList.remove('hidden');
|
||||
} else {
|
||||
seriesBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('blockModal').classList.remove('hidden');
|
||||
},
|
||||
|
||||
@@ -196,7 +207,12 @@ const App = {
|
||||
this._populateDaySelect(date);
|
||||
this._updateDuration();
|
||||
|
||||
document.getElementById('modalDeleteBtn').style.display = 'none';
|
||||
// Hide delete buttons, show series row
|
||||
document.getElementById('modalDeleteBtn').classList.add('hidden');
|
||||
document.getElementById('modalDeleteSeriesBtn').classList.add('hidden');
|
||||
document.getElementById('seriesRow').classList.remove('hidden');
|
||||
document.getElementById('modalAddToAllDays').checked = false;
|
||||
|
||||
document.getElementById('blockModal').classList.remove('hidden');
|
||||
},
|
||||
|
||||
@@ -281,14 +297,32 @@ const App = {
|
||||
if (!start || !end) { this.toast('Zadejte čas začátku a konce', 'error'); return; }
|
||||
|
||||
if (blockId) {
|
||||
// Edit existing
|
||||
// Edit existing block (no series expansion on edit — user edits only this one)
|
||||
const idx = this.state.blocks.findIndex(b => b.id === blockId);
|
||||
if (idx !== -1) {
|
||||
Object.assign(this.state.blocks[idx], { date, title, type_id, start, end, responsible, notes });
|
||||
const existing = this.state.blocks[idx];
|
||||
Object.assign(this.state.blocks[idx], {
|
||||
date, title, type_id, start, end, responsible, notes,
|
||||
series_id: existing.series_id || null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// New block
|
||||
this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes });
|
||||
const addToAll = document.getElementById('modalAddToAllDays').checked;
|
||||
if (addToAll) {
|
||||
// Add a copy to every day in the event range, all sharing a series_id
|
||||
const series_id = this.uid();
|
||||
const dates = this.getDates();
|
||||
for (const d of dates) {
|
||||
this.state.blocks.push({ id: this.uid(), date: d, title, type_id, start, end, responsible, notes, series_id });
|
||||
}
|
||||
document.getElementById('blockModal').classList.add('hidden');
|
||||
this.renderCanvas();
|
||||
this.toast(`Blok přidán do ${dates.length} dnů`, 'success');
|
||||
return;
|
||||
} else {
|
||||
this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes, series_id: null });
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('blockModal').classList.add('hidden');
|
||||
@@ -305,6 +339,23 @@ const App = {
|
||||
this.toast('Blok smazán', 'success');
|
||||
},
|
||||
|
||||
_deleteBlockSeries() {
|
||||
const blockId = document.getElementById('modalBlockId').value;
|
||||
if (!blockId) return;
|
||||
const block = this.state.blocks.find(b => b.id === blockId);
|
||||
if (!block || !block.series_id) {
|
||||
// Fallback: delete just this one
|
||||
this._deleteBlock();
|
||||
return;
|
||||
}
|
||||
const seriesId = block.series_id;
|
||||
const count = this.state.blocks.filter(b => b.series_id === seriesId).length;
|
||||
this.state.blocks = this.state.blocks.filter(b => b.series_id !== seriesId);
|
||||
document.getElementById('blockModal').classList.add('hidden');
|
||||
this.renderCanvas();
|
||||
this.toast(`Série smazána (${count} bloků)`, 'success');
|
||||
},
|
||||
|
||||
// ─── Toast ────────────────────────────────────────────────────────
|
||||
|
||||
toast(message, type = 'success') {
|
||||
@@ -376,6 +427,7 @@ const App = {
|
||||
});
|
||||
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
|
||||
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
|
||||
document.getElementById('modalDeleteSeriesBtn').addEventListener('click', () => this._deleteBlockSeries());
|
||||
|
||||
// Duration ↔ end sync (hours + minutes fields)
|
||||
const durUpdater = () => {
|
||||
|
||||
@@ -148,6 +148,24 @@ def test_swagger_docs(client):
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_series_id_accepted(client):
|
||||
"""Blocks with series_id should be accepted by the validate endpoint."""
|
||||
doc = {
|
||||
"version": "1.0",
|
||||
"event": {"title": "Series Test", "date_from": "2026-03-01", "date_to": "2026-03-02"},
|
||||
"program_types": [{"id": "ws", "name": "Workshop", "color": "#FF0000"}],
|
||||
"blocks": [
|
||||
{"id": "b1", "date": "2026-03-01", "start": "09:00", "end": "10:00",
|
||||
"title": "Morning", "type_id": "ws", "series_id": "s_001"},
|
||||
{"id": "b2", "date": "2026-03-02", "start": "09:00", "end": "10:00",
|
||||
"title": "Morning", "type_id": "ws", "series_id": "s_001"},
|
||||
]
|
||||
}
|
||||
r = client.post("/api/validate", json=doc)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["valid"] is True
|
||||
|
||||
|
||||
def test_backward_compat_date_field(client):
|
||||
"""Old JSON with 'date' (not date_from/date_to) should still validate."""
|
||||
doc = {
|
||||
|
||||
@@ -22,6 +22,13 @@ def test_block_optional_fields():
|
||||
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
|
||||
assert b.responsible is None
|
||||
assert b.notes is None
|
||||
assert b.series_id is None
|
||||
|
||||
|
||||
def test_block_series_id():
|
||||
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws",
|
||||
series_id="s_abc123")
|
||||
assert b.series_id == "s_abc123"
|
||||
|
||||
|
||||
def test_block_with_all_fields():
|
||||
|
||||
Reference in New Issue
Block a user