Compare commits
9 Commits
v0.13.1-dev.3
...
v0.13.5
| Author | SHA1 | Date | |
|---|---|---|---|
| e9006f5a07 | |||
| a11bce17c5 | |||
| 743e5b1c16 | |||
| 4e47ef0c88 | |||
| 0911b362fd | |||
| 71716f339a | |||
| 36ccee38cb | |||
| 9255c87f57 | |||
| a6b246a92b |
+154
-18
@@ -225,12 +225,95 @@ def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_lab
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
summary = {'examined': examined, 'updated': updated, 'failures': failures, 'dry_run': bool(dry_run)}
|
||||
|
||||
@app.route('/api/library_return_by_code', methods=['POST'])
|
||||
def api_library_return_by_code():
|
||||
"""
|
||||
Return a library item by scanning its code only (no student card required).
|
||||
This marks active ausleihungen for the item as completed and updates item status.
|
||||
"""
|
||||
if 'username' not in session:
|
||||
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip()
|
||||
if not item_code_raw:
|
||||
return jsonify({'ok': False, 'message': 'Mediencode fehlt.'}), 400
|
||||
|
||||
normalized_isbn = normalize_and_validate_isbn(item_code_raw)
|
||||
normalized_code = item_code_raw.upper()
|
||||
|
||||
client = None
|
||||
try:
|
||||
_append_audit_event_standalone('student_cards_rollover', summary)
|
||||
except Exception:
|
||||
app.logger.warning('Audit write failed for student_cards_rollover')
|
||||
return summary
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
items_col = db['items']
|
||||
ausleihungen_col = db['ausleihungen']
|
||||
|
||||
query_or = [
|
||||
{'Code_4': item_code_raw},
|
||||
{'Code_4': normalized_code},
|
||||
]
|
||||
if normalized_isbn:
|
||||
query_or.append({'ISBN': normalized_isbn})
|
||||
|
||||
item_doc = items_col.find_one({
|
||||
'ItemType': {'$in': LIBRARY_ITEM_TYPES},
|
||||
'$or': query_or
|
||||
})
|
||||
|
||||
if not item_doc:
|
||||
return jsonify({'ok': False, 'message': 'Kein Bibliotheksmedium für diesen Code gefunden.'}), 404
|
||||
|
||||
item_id = str(item_doc['_id'])
|
||||
now = datetime.datetime.now()
|
||||
|
||||
# If item already available -> nothing to return
|
||||
if item_doc.get('Verfuegbar', True):
|
||||
return jsonify({'ok': False, 'message': 'Dieses Medium ist nicht als ausgeliehen markiert.'}), 409
|
||||
|
||||
# Mark active ausleihungen as completed
|
||||
update_result = ausleihungen_col.update_many(
|
||||
{'Item': item_id, 'Status': 'active'},
|
||||
{'$set': {
|
||||
'Status': 'completed',
|
||||
'End': now,
|
||||
'LastUpdated': now
|
||||
}}
|
||||
)
|
||||
|
||||
# Update item status to available
|
||||
borrower_name = str(item_doc.get('User') or '').strip() or ''
|
||||
it.update_item_status(item_id, True, borrower_name)
|
||||
|
||||
_append_audit_event_standalone(
|
||||
event_type='ausleihung_returned_by_code',
|
||||
payload={
|
||||
'channel': 'library_return_code',
|
||||
'item_id': item_id,
|
||||
'item_name': item_doc.get('Name', ''),
|
||||
'completed_records': update_result.modified_count,
|
||||
'performed_by': session.get('username')
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'action': 'returned',
|
||||
'item_id': item_id,
|
||||
'item_name': item_doc.get('Name', ''),
|
||||
'completed_records': update_result.modified_count,
|
||||
'message': f"{item_doc.get('Name', 'Medium')} wurde zurückgegeben."
|
||||
}), 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error in library return by code: {e}")
|
||||
return jsonify({'ok': False, 'message': 'Fehler beim Verarbeiten der Rückgabe.'}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
|
||||
# Admin route to trigger rollover manually
|
||||
@@ -3231,7 +3314,6 @@ def api_library_items():
|
||||
|
||||
query = {
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']},
|
||||
'IsGroupedSubItem': {'$ne': True},
|
||||
'Deleted': {'$ne': True}
|
||||
}
|
||||
|
||||
@@ -3249,7 +3331,12 @@ def api_library_items():
|
||||
'User': 1,
|
||||
'Ort': 1,
|
||||
'Beschreibung': 1,
|
||||
'Image': 1
|
||||
'Image': 1,
|
||||
'SeriesGroupId': 1,
|
||||
'SeriesCount': 1,
|
||||
'SeriesPosition': 1,
|
||||
'IsGroupedSubItem': 1,
|
||||
'ParentItemId': 1,
|
||||
}
|
||||
|
||||
total_count = items_db.count_documents(query)
|
||||
@@ -3277,6 +3364,10 @@ def api_library_items():
|
||||
'Beschreibung': 1,
|
||||
'Image': 1,
|
||||
'ParentItemId': 1,
|
||||
'SeriesGroupId': 1,
|
||||
'SeriesCount': 1,
|
||||
'SeriesPosition': 1,
|
||||
'IsGroupedSubItem': 1,
|
||||
}
|
||||
child_items = list(items_db.find({
|
||||
'ParentItemId': {'$in': parent_ids_list},
|
||||
@@ -3390,6 +3481,48 @@ def api_library_items():
|
||||
return jsonify({'error': 'An error occurred while fetching library items'}), 500
|
||||
|
||||
|
||||
@app.route('/api/library_group/<series_group_id>')
|
||||
def api_library_group(series_group_id):
|
||||
"""Fetch all items belonging to one library series group."""
|
||||
if 'username' not in session:
|
||||
return jsonify({'items': []}), 401
|
||||
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
items_col = db['items']
|
||||
|
||||
query = {
|
||||
'SeriesGroupId': series_group_id,
|
||||
'Deleted': {'$ne': True},
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']},
|
||||
}
|
||||
projection = {
|
||||
'Name': 1,
|
||||
'ISBN': 1,
|
||||
'Code_4': 1,
|
||||
'Code4': 1,
|
||||
'ItemType': 1,
|
||||
'Ort': 1,
|
||||
'Beschreibung': 1,
|
||||
'SeriesGroupId': 1,
|
||||
'SeriesCount': 1,
|
||||
'SeriesPosition': 1,
|
||||
'IsGroupedSubItem': 1,
|
||||
'ParentItemId': 1,
|
||||
}
|
||||
|
||||
items = list(items_col.find(query, projection).sort([('SeriesPosition', 1), ('Name', 1), ('_id', 1)]))
|
||||
for item in items:
|
||||
item['_id'] = str(item['_id'])
|
||||
|
||||
client.close()
|
||||
return jsonify({'items': items, 'count': len(items), 'series_group_id': series_group_id})
|
||||
except Exception as exc:
|
||||
app.logger.error('Error loading library group %s: %s', series_group_id, exc)
|
||||
return jsonify({'items': [], 'message': 'Gruppe konnte nicht geladen werden.'}), 500
|
||||
|
||||
|
||||
@app.route('/api/library_scan_action', methods=['POST'])
|
||||
def api_library_scan_action():
|
||||
"""
|
||||
@@ -6253,17 +6386,20 @@ def update_group():
|
||||
|
||||
# 1. Shared Fields (Group Logic)
|
||||
# These apply to every item in the group
|
||||
shared_update = {
|
||||
'Name': data.get('name'),
|
||||
'Ort': data.get('ort'),
|
||||
'Beschreibung': data.get('beschreibung'),
|
||||
'Anschaffungsjahr': data.get('ansch_jahr'),
|
||||
'Anschaffungskosten': data.get('ansch_kost'),
|
||||
'Reservierbar': data.get('reservierbar'),
|
||||
'ISBN': data.get('isbn'),
|
||||
'ItemType': data.get('item_type'),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
shared_update = {'LastUpdated': datetime.datetime.now()}
|
||||
for source_key, target_key in (
|
||||
('name', 'Name'),
|
||||
('ort', 'Ort'),
|
||||
('beschreibung', 'Beschreibung'),
|
||||
('ansch_jahr', 'Anschaffungsjahr'),
|
||||
('ansch_kost', 'Anschaffungskosten'),
|
||||
('reservierbar', 'Reservierbar'),
|
||||
('isbn', 'ISBN'),
|
||||
('item_type', 'ItemType'),
|
||||
):
|
||||
value = data.get(source_key)
|
||||
if value is not None:
|
||||
shared_update[target_key] = value
|
||||
|
||||
# 2. Individual Updates (Specific Code Logic)
|
||||
# Expected format: [{'id': '...', 'code_4': '...'}, ...]
|
||||
|
||||
+649
-451
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Fach/Kategorie') }} (Filter 1)</h2>
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Jahrgang') }} (Filter 1)</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'System/Bereich') }} (Filter 2)</h2>
|
||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'Fach') }} (Filter 2)</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
|
||||
|
||||
@@ -812,12 +812,12 @@
|
||||
<div id="range_generator_group" class="form-group" style="display:none; background: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #ddd; margin-bottom: 15px;">
|
||||
<label style="font-weight: bold;">Code-Bereich automatisch generieren:</label>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
|
||||
<input type="text" id="range_prefix" placeholder="Präfix (z.B. IT-)" class="form-control" style="flex: 2;">
|
||||
<input type="text" id="range_prefix" placeholder="Präfix optional (z.B. IT-)" class="form-control" style="flex: 2;">
|
||||
<input type="number" id="range_start" placeholder="Start (z.B. 1)" class="form-control" style="flex: 1;">
|
||||
<span style="font-weight: bold;">bis</span>
|
||||
<input type="text" id="range_end" placeholder="Ende (z.B. 020)" class="form-control" style="flex: 1;">
|
||||
</div>
|
||||
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
|
||||
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Der Präfix ist optional. Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
|
||||
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
|
||||
</div>
|
||||
|
||||
@@ -852,9 +852,9 @@
|
||||
</select>
|
||||
<small style="display:block; color:#666;">Wählen Sie einen Medientyp aus zur Klassifizierung.</small>
|
||||
</div>
|
||||
<h3>Kategorie/Typ:</h3>
|
||||
<h3>Kategorie/Typ/Fach:</h3>
|
||||
<div class="form-group">
|
||||
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
|
||||
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc. (optional)">
|
||||
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1024,6 +1024,90 @@
|
||||
<script>
|
||||
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
||||
|
||||
function getFocusableFormFields(form) {
|
||||
if (!form) return [];
|
||||
return Array.from(form.querySelectorAll('input, select, textarea, button'))
|
||||
.filter((element) => {
|
||||
if (element.disabled || element.getAttribute('aria-hidden') === 'true') return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const type = (element.type || '').toLowerCase();
|
||||
return tagName !== 'button' || type !== 'button';
|
||||
});
|
||||
}
|
||||
|
||||
function focusNextFormField(currentField) {
|
||||
const form = currentField && currentField.form ? currentField.form : null;
|
||||
const fields = getFocusableFormFields(form || document);
|
||||
const currentIndex = fields.indexOf(currentField);
|
||||
|
||||
if (currentIndex >= 0) {
|
||||
for (let i = currentIndex + 1; i < fields.length; i++) {
|
||||
const nextField = fields[i];
|
||||
const nextStyle = window.getComputedStyle(nextField);
|
||||
if (nextStyle.display !== 'none' && nextStyle.visibility !== 'hidden') {
|
||||
nextField.focus();
|
||||
if (nextField.tagName.toLowerCase() === 'select') {
|
||||
nextField.click();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (form) {
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
if (submitButton) {
|
||||
submitButton.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleIsbnLookupFromScanner() {
|
||||
const isbnField = document.getElementById('isbn');
|
||||
if (!isbnField) return false;
|
||||
|
||||
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(isbnField.value) : isbnField.value.trim();
|
||||
if (!normalizedIsbn) return false;
|
||||
|
||||
if (typeof updateIsbnLiveValidation === 'function') {
|
||||
updateIsbnLiveValidation();
|
||||
}
|
||||
|
||||
if (typeof fetchBookInfo === 'function') {
|
||||
fetchBookInfo('upload');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('form').forEach(function (form) {
|
||||
form.addEventListener('keydown', function (event) {
|
||||
const target = event.target;
|
||||
if (event.key !== 'Enter') return;
|
||||
if (!target || target.tagName === 'TEXTAREA' && !event.shiftKey) return;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
const type = (target.type || '').toLowerCase();
|
||||
if (!['input', 'select', 'textarea'].includes(tagName)) return;
|
||||
if (['button', 'submit', 'reset', 'file', 'checkbox', 'radio', 'hidden'].includes(type)) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (target.id === 'isbn' || target.name === 'isbn') {
|
||||
handleIsbnLookupFromScanner();
|
||||
return;
|
||||
}
|
||||
|
||||
focusNextFormField(target);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Function to check if a file is a video
|
||||
function isVideoFile(filename) {
|
||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
|
||||
@@ -1195,7 +1279,7 @@
|
||||
let generatedCodes = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
let numStr = i.toString().padStart(paddingLength, '0');
|
||||
generatedCodes.push(`${prefix}${numStr}`);
|
||||
generatedCodes.push(prefix ? `${prefix}${numStr}` : numStr);
|
||||
}
|
||||
|
||||
const codeField = document.getElementById('code_4');
|
||||
@@ -1385,10 +1469,32 @@
|
||||
scanModeSelect.addEventListener('change', toggleScanMode);
|
||||
}
|
||||
|
||||
// 4. ISBN Live-Validierung
|
||||
// 4. ISBN Live-Validierung und automatische Abfrage nach Scan/Enter
|
||||
const isbnInput = document.getElementById('isbn');
|
||||
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
|
||||
isbnInput.addEventListener('input', updateIsbnLiveValidation);
|
||||
if (isbnInput) {
|
||||
if (typeof updateIsbnLiveValidation === 'function') {
|
||||
isbnInput.addEventListener('input', updateIsbnLiveValidation);
|
||||
}
|
||||
|
||||
let isbnLookupTimer = null;
|
||||
isbnInput.addEventListener('input', function () {
|
||||
clearTimeout(isbnLookupTimer);
|
||||
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(isbnInput.value) : isbnInput.value.trim();
|
||||
if (normalizedIsbn) {
|
||||
isbnLookupTimer = setTimeout(function () {
|
||||
handleIsbnLookupFromScanner();
|
||||
}, 250);
|
||||
}
|
||||
});
|
||||
|
||||
isbnInput.addEventListener('change', function () {
|
||||
if (typeof normalizeIsbnClient === 'function') {
|
||||
const normalizedIsbn = normalizeIsbnClient(isbnInput.value);
|
||||
if (normalizedIsbn) {
|
||||
handleIsbnLookupFromScanner();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Load predefined filter values for dropdowns
|
||||
|
||||
@@ -17,15 +17,11 @@
|
||||
<div class="user-management-container">
|
||||
<h2>Benutzer</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-outline-danger"
|
||||
onclick="return confirm('Sollen alle gespeicherten Klarnamen dauerhaft in Synonym-Kuerzel umgewandelt werden?')"
|
||||
>
|
||||
Gespeicherte Namen anonymisieren
|
||||
</button>
|
||||
</form>
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('register') }}" class="btn btn-success">
|
||||
Neuen Benutzer registrieren
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar mb-3">
|
||||
<div class="row g-2 align-items-end">
|
||||
|
||||
Reference in New Issue
Block a user