Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ae0d7f00e | |||
| 0b09fe489e | |||
| a98f3751e9 | |||
| d722b5a774 | |||
| 0edbef3edd | |||
| d325fa57e8 | |||
| 8a6a842e1a | |||
| b4d61e27f4 | |||
| 318e57475f |
+1
-1
@@ -1 +1 @@
|
||||
v0.8.1
|
||||
v0.8.4
|
||||
|
||||
+240
-75
@@ -74,6 +74,7 @@ import mimetypes
|
||||
import subprocess
|
||||
from Web.modules.inventarsystem.data_protection import (
|
||||
decrypt_document_fields,
|
||||
decrypt_text,
|
||||
encrypt_document_fields,
|
||||
encrypt_soft_deleted_media_pack,
|
||||
)
|
||||
@@ -154,6 +155,119 @@ def _decrypt_student_card_doc(card_doc):
|
||||
return card_doc
|
||||
return decrypt_document_fields(card_doc, STUDENT_CARD_ENCRYPTED_FIELDS)
|
||||
|
||||
|
||||
def _parse_and_increment_class(klass, max_class=13, graduate_label=''):
|
||||
"""Parse a class string like '7A' or '10' and increment the numeric part.
|
||||
If the numeric part would exceed max_class, return graduate_label (or empty).
|
||||
Leaves non-numeric / unparsable values unchanged.
|
||||
"""
|
||||
if not klass:
|
||||
return klass
|
||||
s = str(klass).strip()
|
||||
m = re.match(r"^\s*(\d{1,2})(\D.*)?$", s)
|
||||
if not m:
|
||||
return s
|
||||
try:
|
||||
num = int(m.group(1))
|
||||
except Exception:
|
||||
return s
|
||||
suffix = m.group(2) or ''
|
||||
new_num = num + 1
|
||||
if max_class is not None and new_num > int(max_class):
|
||||
return graduate_label or ''
|
||||
return f"{new_num}{suffix}"
|
||||
|
||||
|
||||
def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_label=''):
|
||||
"""Increment class years on all student cards.
|
||||
|
||||
Returns a summary dict with counts.
|
||||
"""
|
||||
client = None
|
||||
updated = 0
|
||||
examined = 0
|
||||
failures = 0
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
col = db['student_cards']
|
||||
now = datetime.datetime.now()
|
||||
|
||||
cursor = list(col.find({}, {'Klasse': 1}))
|
||||
for doc in cursor:
|
||||
examined += 1
|
||||
try:
|
||||
# Decrypt to work on plain class string
|
||||
plain = dict(doc)
|
||||
plain = _decrypt_student_card_doc(plain)
|
||||
orig = (plain.get('Klasse') or '').strip()
|
||||
new_class = _parse_and_increment_class(orig, max_class=max_class, graduate_label=graduate_label)
|
||||
if new_class != orig:
|
||||
if dry_run:
|
||||
updated += 1
|
||||
continue
|
||||
payload = {'Klasse': new_class}
|
||||
encrypted = encrypt_document_fields(payload, STUDENT_CARD_ENCRYPTED_FIELDS)
|
||||
update_doc = {'Klasse': encrypted.get('Klasse'), 'Aktualisiert': now}
|
||||
col.update_one({'_id': doc.get('_id')}, {'$set': update_doc})
|
||||
updated += 1
|
||||
except Exception:
|
||||
failures += 1
|
||||
app.logger.exception('Failed to process student card rollover for %s', doc.get('_id'))
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
summary = {'examined': examined, 'updated': updated, 'failures': failures, 'dry_run': bool(dry_run)}
|
||||
try:
|
||||
_append_audit_event_standalone('student_cards_rollover', summary)
|
||||
except Exception:
|
||||
app.logger.warning('Audit write failed for student_cards_rollover')
|
||||
return summary
|
||||
|
||||
|
||||
# Admin route to trigger rollover manually
|
||||
@app.route('/admin/trigger_school_year_rollover', methods=['POST'])
|
||||
def admin_trigger_school_year_rollover():
|
||||
if 'username' not in session:
|
||||
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
|
||||
if not us.check_admin(session['username']):
|
||||
return jsonify({'ok': False, 'message': 'Administratorrechte erforderlich.'}), 403
|
||||
|
||||
max_class = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', '13'))
|
||||
graduate_label = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', '')
|
||||
dry = request.args.get('dry', '0') in ('1', 'true', 'yes')
|
||||
summary = rollover_student_card_classes(dry_run=dry, max_class=max_class, graduate_label=graduate_label)
|
||||
return jsonify({'ok': True, 'summary': summary}), 200
|
||||
|
||||
|
||||
# Schedule annual rollover job using APScheduler (configurable via env)
|
||||
try:
|
||||
if cfg.SCHEDULER_ENABLED:
|
||||
_rollover_month = getattr(cfg, 'SCHOOL_ROLLOVER_MONTH', 9)
|
||||
_rollover_day = getattr(cfg, 'SCHOOL_ROLLOVER_DAY', 1)
|
||||
_rollover_hour = getattr(cfg, 'SCHOOL_ROLLOVER_HOUR', 3)
|
||||
_rollover_minute = getattr(cfg, 'SCHOOL_ROLLOVER_MIN', 0)
|
||||
_rollover_max_class = getattr(cfg, 'SCHOOL_ROLLOVER_MAX_CLASS', 13)
|
||||
_rollover_grad_label = getattr(cfg, 'SCHOOL_ROLLOVER_GRADUATE_LABEL', '')
|
||||
|
||||
_scheduler = BackgroundScheduler()
|
||||
# Use a cron-style yearly job on the configured month/day
|
||||
_scheduler.add_job(
|
||||
func=lambda: rollover_student_card_classes(dry_run=False, max_class=_rollover_max_class, graduate_label=_rollover_grad_label),
|
||||
trigger='cron',
|
||||
month=_rollover_month,
|
||||
day=_rollover_day,
|
||||
hour=_rollover_hour,
|
||||
minute=_rollover_minute,
|
||||
id='school_year_rollover',
|
||||
replace_existing=True
|
||||
)
|
||||
_scheduler.start()
|
||||
app.logger.info('Scheduled annual school year rollover: %s-%s %s:%s', _rollover_month, _rollover_day, _rollover_hour, _rollover_minute)
|
||||
except Exception as e:
|
||||
app.logger.warning('Failed to schedule school year rollover: %s', e)
|
||||
|
||||
# Thumbnail sizes
|
||||
THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE
|
||||
PREVIEW_SIZE = cfg.PREVIEW_SIZE
|
||||
@@ -3053,6 +3167,7 @@ def api_library_items():
|
||||
|
||||
query = {
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
|
||||
'IsGroupedSubItem': {'$ne': True},
|
||||
'Deleted': {'$ne': True}
|
||||
}
|
||||
|
||||
@@ -3078,7 +3193,39 @@ def api_library_items():
|
||||
raw_items = list(items_db.find(query, projection).sort([('Name', 1), ('_id', 1)]).skip(offset).limit(limit))
|
||||
|
||||
# Build maps for grouping by parent-child relationship (grouped sub-items)
|
||||
all_ids = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
|
||||
parent_ids_list = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
|
||||
children_by_parent = {}
|
||||
child_items = []
|
||||
if parent_ids_list:
|
||||
child_projection = {
|
||||
'Name': 1,
|
||||
'Autor': 1,
|
||||
'Author': 1,
|
||||
'ISBN': 1,
|
||||
'Code_4': 1,
|
||||
'Code4': 1,
|
||||
'ItemType': 1,
|
||||
'Verfuegbar': 1,
|
||||
'Condition': 1,
|
||||
'HasDamage': 1,
|
||||
'User': 1,
|
||||
'Ort': 1,
|
||||
'Beschreibung': 1,
|
||||
'Image': 1,
|
||||
'ParentItemId': 1,
|
||||
}
|
||||
child_items = list(items_db.find({
|
||||
'ParentItemId': {'$in': parent_ids_list},
|
||||
'IsGroupedSubItem': True,
|
||||
'Deleted': {'$ne': True}
|
||||
}, child_projection))
|
||||
for child in child_items:
|
||||
parent_id = str(child.get('ParentItemId') or '')
|
||||
if not parent_id:
|
||||
continue
|
||||
children_by_parent.setdefault(parent_id, []).append(child)
|
||||
|
||||
all_ids = parent_ids_list + [str(child.get('_id')) for child in child_items if child.get('_id')]
|
||||
active_records = []
|
||||
if all_ids:
|
||||
active_records = list(ausleihungen_db.find({'Item': {'$in': all_ids}, 'Status': 'active'}, {'Item': 1, 'User': 1}))
|
||||
@@ -3093,26 +3240,10 @@ def api_library_items():
|
||||
if item_id not in active_user_by_item:
|
||||
active_user_by_item[item_id] = rec.get('User', '')
|
||||
|
||||
# Organize children under their parent (ParentItemId) and prepare parent list
|
||||
items_by_id = {}
|
||||
children_by_parent = {}
|
||||
parent_ids = set()
|
||||
for itm in raw_items:
|
||||
iid = str(itm.get('_id'))
|
||||
items_by_id[iid] = itm
|
||||
parent = str(itm.get('ParentItemId') or '')
|
||||
if parent:
|
||||
children_by_parent.setdefault(parent, []).append(itm)
|
||||
else:
|
||||
parent_ids.add(iid)
|
||||
|
||||
# Build aggregated list: for each parent id, include parent + children as one entry
|
||||
# Build aggregated list: iterate ordered parent list and include children like inventory module
|
||||
aggregated = []
|
||||
processed = set()
|
||||
for pid in list(parent_ids):
|
||||
parent = items_by_id.get(pid)
|
||||
if not parent:
|
||||
continue
|
||||
for parent in raw_items:
|
||||
pid = str(parent.get('_id'))
|
||||
children = children_by_parent.get(pid, [])
|
||||
|
||||
# Compute grouped counts and availability
|
||||
@@ -3137,22 +3268,33 @@ def api_library_items():
|
||||
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
|
||||
has_active_borrow = any(str(unit.get('_id')) in active_item_ids for unit in grouped_units)
|
||||
|
||||
if has_damage and not has_active_borrow:
|
||||
# Prefer 'available' when any grouped unit is available
|
||||
if has_damage and len(available_units) == 0:
|
||||
doc['LibraryDisplayStatus'] = 'damaged'
|
||||
elif len(available_units) > 0:
|
||||
doc['LibraryDisplayStatus'] = 'available'
|
||||
elif has_active_borrow or doc.get('Verfuegbar') is False:
|
||||
doc['LibraryDisplayStatus'] = 'borrowed'
|
||||
else:
|
||||
doc['LibraryDisplayStatus'] = 'available'
|
||||
|
||||
# Determine borrower: prefer any active borrow on group units, fallback to parent.User
|
||||
borrower = active_user_by_item.get(str(parent.get('_id'))) or ''
|
||||
# Determine borrower: prefer any active borrow on grouped units, fallback to parent.User
|
||||
borrower = ''
|
||||
for unit in grouped_units:
|
||||
u_id = str(unit.get('_id'))
|
||||
if active_user_by_item.get(u_id):
|
||||
borrower = active_user_by_item.get(u_id)
|
||||
break
|
||||
if not borrower:
|
||||
for unit in grouped_units:
|
||||
u_id = str(unit.get('_id'))
|
||||
if active_user_by_item.get(u_id):
|
||||
borrower = active_user_by_item.get(u_id)
|
||||
break
|
||||
doc['BorrowedBy'] = borrower or doc.get('User', '')
|
||||
borrower = doc.get('User', '')
|
||||
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
||||
try:
|
||||
borrower = decrypt_text(borrower) if borrower else ''
|
||||
except Exception:
|
||||
# Fallback: keep original string if decryption fails
|
||||
borrower = borrower or ''
|
||||
|
||||
doc['BorrowedBy'] = borrower
|
||||
|
||||
doc['GroupedDisplayCount'] = 1 + len(children)
|
||||
doc['Quantity'] = doc['GroupedDisplayCount']
|
||||
@@ -3161,40 +3303,6 @@ def api_library_items():
|
||||
doc['GroupedAllCodes'] = grouped_all_codes
|
||||
|
||||
aggregated.append(doc)
|
||||
processed.add(pid)
|
||||
for c in children:
|
||||
processed.add(str(c.get('_id')))
|
||||
|
||||
# Include any remaining items (orphans or standalones not parented)
|
||||
for itm in raw_items:
|
||||
iid = str(itm.get('_id'))
|
||||
if iid in processed:
|
||||
continue
|
||||
doc = dict(itm)
|
||||
doc['_id'] = iid
|
||||
if doc.get('Code4') in (None, '') and doc.get('Code_4') not in (None, ''):
|
||||
doc['Code4'] = doc.get('Code_4')
|
||||
|
||||
condition_value = str(doc.get('Condition', '')).strip().lower()
|
||||
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
|
||||
has_active_borrow = iid in active_item_ids
|
||||
|
||||
if has_damage and not has_active_borrow:
|
||||
doc['LibraryDisplayStatus'] = 'damaged'
|
||||
elif has_active_borrow or doc.get('Verfuegbar') is False:
|
||||
doc['LibraryDisplayStatus'] = 'borrowed'
|
||||
else:
|
||||
doc['LibraryDisplayStatus'] = 'available'
|
||||
|
||||
doc['BorrowedBy'] = active_user_by_item.get(iid) or doc.get('User', '')
|
||||
# Single item: grouped count = 1
|
||||
doc['GroupedDisplayCount'] = 1
|
||||
doc['Quantity'] = 1
|
||||
doc['AvailableGroupedCount'] = 1 if doc.get('Verfuegbar', True) else 0
|
||||
doc['GroupedAvailableUnits'] = [{'id': iid, 'code': doc.get('Code_4') or doc.get('Code4') or '', 'label': f"{doc.get('Code_4') or doc.get('Code4') or '-'} ({doc.get('Name','')})"}] if doc.get('Verfuegbar', True) else []
|
||||
doc['GroupedAllCodes'] = [doc.get('Code_4') or doc.get('Code4') or '']
|
||||
|
||||
aggregated.append(doc)
|
||||
|
||||
client.close()
|
||||
|
||||
@@ -3384,28 +3492,82 @@ def api_item_detail(item_id):
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
ausleihungen_col = db['ausleihungen']
|
||||
items_col = db['items']
|
||||
|
||||
active_borrow = ausleihungen_col.find_one(
|
||||
{'Item': str(item.get('_id')), 'Status': 'active'},
|
||||
{'User': 1}
|
||||
)
|
||||
client.close()
|
||||
# Determine parent/group id (if this is a child, use its ParentItemId)
|
||||
parent_id = str(item.get('ParentItemId') or str(item.get('_id')))
|
||||
|
||||
# Load all group units (parent + children)
|
||||
group_units_cursor = items_col.find({
|
||||
'$or': [
|
||||
{'_id': ObjectId(parent_id)},
|
||||
{'ParentItemId': parent_id}
|
||||
],
|
||||
'Deleted': {'$ne': True}
|
||||
})
|
||||
group_units = list(group_units_cursor)
|
||||
if not group_units:
|
||||
# Fallback: just use the single item
|
||||
group_units = [item]
|
||||
|
||||
group_unit_ids = [str(u.get('_id')) for u in group_units if u.get('_id')]
|
||||
|
||||
# Fetch all borrow records for the whole group (active + history)
|
||||
borrow_records = list(ausleihungen_col.find({'Item': {'$in': group_unit_ids}}).sort('Start', -1))
|
||||
|
||||
# Compute availability and damage state across group
|
||||
available_units = [u for u in group_units if u.get('Verfuegbar', True)]
|
||||
condition_value = str(item.get('Condition', '')).strip().lower()
|
||||
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
|
||||
if has_damage and not active_borrow:
|
||||
|
||||
if has_damage and len(available_units) == 0:
|
||||
status_label = 'Defekt/Zerstört'
|
||||
elif item.get('Verfuegbar') is False or active_borrow:
|
||||
elif len(available_units) > 0:
|
||||
status_label = 'Verfügbar'
|
||||
elif any(r.get('Status') == 'active' for r in borrow_records):
|
||||
status_label = 'Ausgeliehen'
|
||||
else:
|
||||
status_label = 'Verfügbar'
|
||||
|
||||
# Prefer the most recent active borrower across the group
|
||||
borrower_value = ''
|
||||
if active_borrow:
|
||||
borrower_value = active_borrow.get('User', '')
|
||||
elif item.get('User'):
|
||||
borrower_value = item.get('User')
|
||||
|
||||
for rec in borrow_records:
|
||||
if rec.get('Status') == 'active' and rec.get('User'):
|
||||
try:
|
||||
borrower_value = decrypt_text(rec.get('User'))
|
||||
except Exception:
|
||||
borrower_value = rec.get('User')
|
||||
break
|
||||
if not borrower_value and item.get('User'):
|
||||
try:
|
||||
borrower_value = decrypt_text(item.get('User'))
|
||||
except Exception:
|
||||
borrower_value = item.get('User')
|
||||
|
||||
# Helper to format datetimes
|
||||
def fmt_dt(dt):
|
||||
try:
|
||||
return dt.strftime('%d.%m.%Y %H:%M') if dt else ''
|
||||
except Exception:
|
||||
return str(dt) if dt else ''
|
||||
|
||||
# Build HTML for borrow records (if any)
|
||||
borrows_html = ''
|
||||
if borrow_records:
|
||||
rows = []
|
||||
for rec in borrow_records:
|
||||
user_raw = rec.get('User') or ''
|
||||
try:
|
||||
user = decrypt_text(user_raw) if user_raw else ''
|
||||
except Exception:
|
||||
user = user_raw
|
||||
status = rec.get('Status', '')
|
||||
start = fmt_dt(rec.get('Start'))
|
||||
end = fmt_dt(rec.get('End'))
|
||||
notes = html.escape(str(rec.get('Notes') or ''))
|
||||
rows.append(f"<li><strong>{html.escape(user or '-')}</strong> — {html.escape(status)} — {html.escape(start)} → {html.escape(end)}{(' — ' + notes) if notes else ''}</li>")
|
||||
borrows_html = f"<h3>Ausleihhistorie</h3><ul>{''.join(rows)}</ul>"
|
||||
|
||||
# Basic detail HTML
|
||||
detail_html = f"""
|
||||
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
|
||||
@@ -3414,7 +3576,10 @@ def api_item_detail(item_id):
|
||||
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
|
||||
<p><strong>Status:</strong> {html.escape(status_label)}</p>
|
||||
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
|
||||
{borrows_html}
|
||||
"""
|
||||
client.close()
|
||||
return detail_html, 200
|
||||
return detail_html, 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching item detail: {e}")
|
||||
|
||||
@@ -18,14 +18,6 @@ Collection Structure:
|
||||
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
|
||||
- Status fields: Verfuegbar, User (if currently borrowed)
|
||||
"""
|
||||
'''
|
||||
Copyright 2025-2026 AIIrondev
|
||||
|
||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||
See Legal/LICENSE for the full license text.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
@@ -71,6 +71,15 @@ DEFAULTS = {
|
||||
'logo_path': '',
|
||||
'logo_thumb': '',
|
||||
'logo_thumb': '',
|
||||
# School-level rollover configuration for advancing class years
|
||||
'rollover': {
|
||||
'month': 9,
|
||||
'day': 1,
|
||||
'hour': 3,
|
||||
'minute': 0,
|
||||
'max_class': 13,
|
||||
'graduate_label': ''
|
||||
},
|
||||
},
|
||||
'schoolPeriods': {
|
||||
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
|
||||
@@ -157,32 +166,18 @@ def _get_int_env(name, default):
|
||||
def get_version():
|
||||
# Prefer an explicit release marker if present (created by release process).
|
||||
project_root = os.path.abspath(os.path.join(BASE_DIR, '..', '..', '..'))
|
||||
release_file = os.path.join(project_root, '.release-version')
|
||||
release_file = os.path.join(project_root, '.docker-build.env')
|
||||
try:
|
||||
if os.path.isfile(release_file):
|
||||
with open(release_file, 'r', encoding='utf-8') as f:
|
||||
val = f.read().strip()
|
||||
if val:
|
||||
return val
|
||||
var = f.readline()
|
||||
for i in var:
|
||||
i = i.split(":")
|
||||
i.pop[0]
|
||||
return str(i)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to .docker-build.env (legacy behaviour)
|
||||
env_path = os.path.join(project_root, '.docker-build.env')
|
||||
try:
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
for l in f:
|
||||
if l.startswith('INVENTAR_APP_IMAGE='):
|
||||
return l.split(':', 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final fallback: use config.json value or packaged default
|
||||
try:
|
||||
return _get(_conf, ['ver'], DEFAULTS.get('version', '0.0.0'))
|
||||
except Exception:
|
||||
return DEFAULTS.get('version', '0.0.0')
|
||||
|
||||
# Expose settings
|
||||
APP_VERSION = get_version()
|
||||
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
|
||||
@@ -221,10 +216,30 @@ SSL_ENABLED = _get(_conf, ['ssl', 'enabled'], DEFAULTS['ssl']['enabled'])
|
||||
SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
|
||||
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
|
||||
|
||||
# Email settings
|
||||
EMAIL_ENABLED = _get(_conf, ['email', 'enabled'], False)
|
||||
EMAIL_SMTP_HOST = _get(_conf, ['email', 'smtp_host'], 'smtp.gmail.com')
|
||||
EMAIL_SMTP_PORT = int(_get(_conf, ['email', 'smtp_port'], 587))
|
||||
EMAIL_USE_TLS = bool(_get(_conf, ['email', 'use_tls'], True))
|
||||
EMAIL_USERNAME = _get(_conf, ['email', 'username'], '')
|
||||
EMAIL_PASSWORD = _get(_conf, ['email', 'password'], '')
|
||||
EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
|
||||
EMAIL_DEFAULT_SENDER_NAME = _get(_conf, ['email', 'default_sender_name'], 'Inventarsystem')
|
||||
EMAIL_TIMEOUT_SECONDS = int(_get(_conf, ['email', 'timeout_seconds'], 30))
|
||||
|
||||
# School periods
|
||||
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
||||
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
|
||||
|
||||
# School rollover configuration (can be set in config.json under `school.rollover`)
|
||||
SCHOOL_ROLLOVER = _get(_conf, ['school', 'rollover'], DEFAULTS['school'].get('rollover', {}))
|
||||
SCHOOL_ROLLOVER_MONTH = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MONTH', str(SCHOOL_ROLLOVER.get('month', 9))))
|
||||
SCHOOL_ROLLOVER_DAY = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_DAY', str(SCHOOL_ROLLOVER.get('day', 1))))
|
||||
SCHOOL_ROLLOVER_HOUR = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_HOUR', str(SCHOOL_ROLLOVER.get('hour', 3))))
|
||||
SCHOOL_ROLLOVER_MIN = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MIN', str(SCHOOL_ROLLOVER.get('minute', 0))))
|
||||
SCHOOL_ROLLOVER_MAX_CLASS = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', str(SCHOOL_ROLLOVER.get('max_class', 13))))
|
||||
SCHOOL_ROLLOVER_GRADUATE_LABEL = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', str(SCHOOL_ROLLOVER.get('graduate_label', '')))
|
||||
|
||||
# Optional feature modules
|
||||
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Apointment Managment
|
||||
=========================
|
||||
|
||||
This module manages appointments in the database. It provides comprehensive
|
||||
functionality for creating, updating, retrieving appointments items.
|
||||
|
||||
Key Features:
|
||||
- Creating and updating appointments
|
||||
- Retrieving items by ID
|
||||
- Managing time slots
|
||||
- client retrival
|
||||
|
||||
Collection Structure:
|
||||
- appointments:
|
||||
- Required fields: user, start_date, end_date, daytime, slots, slot_time
|
||||
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
|
||||
- Status fields: slots_used_by
|
||||
"""
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
|
||||
def _active_record_query(extra_query=None):
|
||||
"""Build a query that excludes logically deleted records."""
|
||||
base_query = {'Deleted': {'$ne': True}}
|
||||
if extra_query:
|
||||
base_query.update(extra_query)
|
||||
return base_query
|
||||
|
||||
|
||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str=""):
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
|
||||
item = {
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'time_span': time_span,
|
||||
'slots': slots,
|
||||
'slot_lenght': slot_lenght,
|
||||
'user': user,
|
||||
'mail': mail,
|
||||
'note': note,
|
||||
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||
'Created': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
result = items.insert_one(item)
|
||||
return result.inserted_id
|
||||
except Exception as e:
|
||||
print(f"Exception accured: {e}")
|
||||
|
||||
|
||||
def get_item(id):
|
||||
"""
|
||||
Retrieve a specific appointment by its ID.
|
||||
|
||||
Args:
|
||||
id (str): ID of the appointsment to retrieve
|
||||
|
||||
Returns:
|
||||
dict: The appointment document or None if not found
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
||||
client.close()
|
||||
return item
|
||||
except Exception as e:
|
||||
print(f"Error retrieving item: {e}")
|
||||
return None
|
||||
|
||||
def update(id,slots_used: list):
|
||||
"""
|
||||
Update an existing appointment.
|
||||
|
||||
Args:
|
||||
id (str): ID of the item to update
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
|
||||
update_data = {
|
||||
'slots_booked': [slots_used],
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
|
||||
result = items.update_one(
|
||||
{'_id': ObjectId(id)},
|
||||
{'$set': update_data}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error updating item: {e}")
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import smtplib
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
|
||||
def _build_smtp_client():
|
||||
smtp = smtplib.SMTP(cfg.EMAIL_SMTP_HOST, cfg.EMAIL_SMTP_PORT, timeout=cfg.EMAIL_TIMEOUT_SECONDS)
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USE_TLS:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USERNAME:
|
||||
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or '')
|
||||
return smtp
|
||||
|
||||
def send(email: list, subject: str, note: str, sender: str) -> bool:
|
||||
"""
|
||||
Sends the email with the link to the Clients
|
||||
|
||||
Input:
|
||||
- email: Email list of all the addresses to send the link to ["","",""]
|
||||
- subject: Subject of the email
|
||||
- note: Note that is send with the Emails
|
||||
|
||||
Output:
|
||||
- bool: true if the sending worked and false if it didnt
|
||||
"""
|
||||
msg = MIMEMultipart()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||
msg.attach(MIMEText(note))
|
||||
smtp = None
|
||||
try:
|
||||
smtp = _build_smtp_client()
|
||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
if smtp:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1 +1 @@
|
||||
print("hello")
|
||||
# Web.modules package initialization
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Class for all funktions of the executive -> Lehrer
|
||||
"""
|
||||
import datetime
|
||||
import emailservice.email as mail_service
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.settings as cfg
|
||||
from tenant import get_tenant_context
|
||||
|
||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="") -> str:
|
||||
"""
|
||||
Generates a link for the executive to send to his clients to book a time Slot
|
||||
|
||||
Input:
|
||||
- date_start: start of the time frae area
|
||||
- date_end: end of the time frame area
|
||||
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
|
||||
- slots: amount of slots that are available
|
||||
- slot_lenght: the lenght of a slot in minutes
|
||||
|
||||
Output:
|
||||
- link: The link for the user to send to the clients
|
||||
"""
|
||||
id = termin.add(date_start, date_end, time_span, slots, slot_lenght, user, mail, note)
|
||||
|
||||
tenant_context = get_tenant_context()
|
||||
subdomain = ''
|
||||
if tenant_context:
|
||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||
|
||||
host = f"https://{subdomain}.invario.eu" if subdomain else "invario.eu"
|
||||
link = host + "/terminplaner/client" + "?" + "client_id=" + id
|
||||
subject = f"Terminanfrage von {user}"
|
||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||
mail_service.send(mail, subject, note_link)
|
||||
return link
|
||||
|
||||
|
||||
def book_slot(id, date_start_time, name):
|
||||
"""
|
||||
Updates slot for the booking per a id
|
||||
|
||||
Input:
|
||||
- id: the id is the id you get from the
|
||||
- date_start_time: the date of the booking that was selected with date and time
|
||||
- name: name that the client gave himself
|
||||
|
||||
Output:
|
||||
- bool: if worked or not
|
||||
"""
|
||||
termin.update()
|
||||
@@ -754,7 +754,8 @@
|
||||
const resp = await fetch(`/delete_library_item/${itemId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
'X-CSRF-Token': '{{ csrf_token }}'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
@@ -863,11 +864,27 @@
|
||||
setActiveStudentCard(cardId);
|
||||
|
||||
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
|
||||
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
|
||||
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
|
||||
let borrowCount = parseInt(countPrompt || '1', 10);
|
||||
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
|
||||
borrowCount = 1;
|
||||
}
|
||||
if (borrowCount > maxAvailable) {
|
||||
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/ausleihen/${itemId}`;
|
||||
|
||||
const csrfField = document.createElement('input');
|
||||
csrfField.type = 'hidden';
|
||||
csrfField.name = 'csrf_token';
|
||||
csrfField.value = '{{ csrf_token }}';
|
||||
form.appendChild(csrfField);
|
||||
|
||||
const cardField = document.createElement('input');
|
||||
cardField.type = 'hidden';
|
||||
cardField.name = 'borrower_card_id';
|
||||
@@ -888,6 +905,12 @@
|
||||
form.appendChild(durationField);
|
||||
}
|
||||
|
||||
const countField = document.createElement('input');
|
||||
countField.type = 'hidden';
|
||||
countField.name = 'exemplare_count';
|
||||
countField.value = String(borrowCount || 1);
|
||||
form.appendChild(countField);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
+11
@@ -19,6 +19,17 @@
|
||||
"cert": "Web/certs/cert.pem",
|
||||
"key": "Web/certs/key.pem"
|
||||
},
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": true,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from_address": "",
|
||||
"default_sender_name": "Invario Inventurprogramm",
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
"images": {
|
||||
"thumbnail_size": [
|
||||
150,
|
||||
|
||||
Reference in New Issue
Block a user