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

This commit is contained in:
2026-02-20 17:58:56 +01:00
parent b91f336c12
commit b494d29790
7 changed files with 165 additions and 12 deletions

View File

@@ -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 - **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 - **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š - **JSON import/export** — uložíš scénář, kdykoli ho znovu načteš
- **Vzorový JSON** — `GET /api/sample` - **Vzorový JSON** — `GET /api/sample`
- **PDF výstup** — A4 na šířku, vždy 1 stránka, barevné bloky dle typů, legenda - **PDF výstup** — A4 na šířku, vždy 1 stránka, barevné bloky dle typů, legenda
- Garant viditelný přímo v bloku - Garant viditelný přímo v bloku
- Bloky s poznámkou mají horní index (¹ ² ³...) - Bloky s poznámkou mají horní index (¹ ² ³...)
- Stránka 2 (pokud jsou poznámky): výpis všech poznámek ke scénáři - 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 - **Dokumentace na webu** — záložka "Dokumentace" přímo v aplikaci
- **Swagger UI** — `GET /docs` - **Swagger UI** — `GET /docs`
@@ -60,7 +62,7 @@ open http://localhost:8080
python3 -m pytest tests/ -v 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", "title": "Název bloku",
"type_id": "main", "type_id": "main",
"responsible": "Garant (volitelné)", "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)"
} }
] ]
} }

View File

@@ -16,6 +16,7 @@ class Block(BaseModel):
type_id: str type_id: str
responsible: Optional[str] = None responsible: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
series_id: Optional[str] = None # shared across blocks added via "add to all days"
class ProgramType(BaseModel): class ProgramType(BaseModel):

View File

@@ -946,3 +946,61 @@ body {
margin-bottom: 4px; margin-bottom: 4px;
color: var(--header-bg); 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;
}

View File

@@ -13,7 +13,7 @@
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<h1 class="header-title">Scenár Creator</h1> <h1 class="header-title">Scenár Creator</h1>
<span class="header-version">v4.0</span> <span class="header-version">v4.2</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<label class="btn btn-secondary btn-sm" id="importJsonBtn"> <label class="btn btn-secondary btn-sm" id="importJsonBtn">
@@ -84,7 +84,7 @@
<div class="tab-content hidden" id="tab-docs"> <div class="tab-content hidden" id="tab-docs">
<div class="docs-container"> <div class="docs-container">
<h2>Scenár Creator — Dokumentace</h2> <h2>Scenár Creator — Dokumentace</h2>
<p class="docs-version">Verze 4.2 &nbsp;|&nbsp; <a href="/docs" target="_blank">Swagger API</a> &nbsp;|&nbsp; <a href="/api/sample">Vzorový JSON</a></p> <p class="docs-version">Verze 4.2.0 &nbsp;|&nbsp; <a href="/docs" target="_blank">Swagger API</a> &nbsp;|&nbsp; <a href="/api/sample">Vzorový JSON</a></p>
<h3>Jak začít</h3> <h3>Jak začít</h3>
<ol> <ol>
@@ -103,10 +103,12 @@
<h3>Práce s bloky</h3> <h3>Práce s bloky</h3>
<ul> <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í:</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>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>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>Úprava:</strong> Klikněte na blok — otevře se formulář s editací jednoho bloku.</li>
<li><strong>Smazání:</strong> V editačním formuláři klikněte na „Smazat blok".</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> </ul>
<h3>Formulář bloku</h3> <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[].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[].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[].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> </tbody>
</table> </table>
@@ -216,9 +219,20 @@
<label>Poznámka</label> <label>Poznámka</label>
<textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea> <textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea>
</div> </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>
<div class="modal-footer"> <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> <button class="btn btn-primary btn-sm" id="modalSaveBtn">Uložit</button>
</div> </div>
</div> </div>

View File

@@ -178,7 +178,18 @@ const App = {
this._populateDaySelect(block.date); this._populateDaySelect(block.date);
this._updateDuration(); 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'); document.getElementById('blockModal').classList.remove('hidden');
}, },
@@ -196,7 +207,12 @@ const App = {
this._populateDaySelect(date); this._populateDaySelect(date);
this._updateDuration(); 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'); 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 (!start || !end) { this.toast('Zadejte čas začátku a konce', 'error'); return; }
if (blockId) { 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); const idx = this.state.blocks.findIndex(b => b.id === blockId);
if (idx !== -1) { 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 { } else {
// New block // 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'); document.getElementById('blockModal').classList.add('hidden');
@@ -305,6 +339,23 @@ const App = {
this.toast('Blok smazán', 'success'); 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 ────────────────────────────────────────────────────────
toast(message, type = 'success') { toast(message, type = 'success') {
@@ -376,6 +427,7 @@ const App = {
}); });
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal()); document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock()); document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
document.getElementById('modalDeleteSeriesBtn').addEventListener('click', () => this._deleteBlockSeries());
// Duration ↔ end sync (hours + minutes fields) // Duration ↔ end sync (hours + minutes fields)
const durUpdater = () => { const durUpdater = () => {

View File

@@ -148,6 +148,24 @@ def test_swagger_docs(client):
assert r.status_code == 200 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): def test_backward_compat_date_field(client):
"""Old JSON with 'date' (not date_from/date_to) should still validate.""" """Old JSON with 'date' (not date_from/date_to) should still validate."""
doc = { doc = {

View File

@@ -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") b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
assert b.responsible is None assert b.responsible is None
assert b.notes 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(): def test_block_with_all_fields():