Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87caa3813b | |||
| 1dba9730c5 | |||
|
760f5d1463
|
|||
| 3cccbd8894 | |||
|
59d52736a0
|
|||
| 769153d953 | |||
| 4b4711f045 | |||
|
3da1c14b63
|
|||
|
8491c41428
|
|||
|
cee3379203
|
|||
| 6eb34256e6 | |||
| 68ebaff9ef | |||
|
9dd4b0328e
|
|||
|
|
d6883b0388 | ||
| 9e64d10bf4 | |||
|
9f1dfa1030
|
|||
|
|
8c42d5fa45 | ||
| d3dd6ae531 | |||
| a8ebaee154 | |||
|
84b00409f7
|
|||
|
2d54d64a46
|
|||
|
6c6d140c2f
|
|||
| ee62c65ae7 | |||
| a4460ec17b | |||
| ab62212201 | |||
| dcb5a44ceb | |||
| f63bcc8446 | |||
| 0764a6b06a | |||
| ef21a18b87 | |||
| 0406fe4f6f | |||
| 6af34bf2df | |||
| 560d8285b5 | |||
| b30d1aac47 | |||
| 3cc6e793d2 | |||
| 7e07bdea0c | |||
| 06965db26a | |||
| 0df7fd9fe6 | |||
| 713dbc1a1d | |||
| e061c1f5a9 | |||
| 8e9eff4f3a | |||
| 6a11b3482e | |||
| d35b2e816e | |||
| 11d5d67538 | |||
| ebf8363b2a | |||
| a2631570ec | |||
| 9831aa3a62 | |||
| c4be1d8bfa | |||
| 7079b4d47f | |||
| 65c86a65cd | |||
| f4e75831d5 | |||
| 4f28cfe55c | |||
| 8b8c1c9393 | |||
| 247db562b1 | |||
| 1263faa23f | |||
| fd6684cc47 | |||
| 1ee7901d49 | |||
| e934a2b3f1 | |||
| 7be9dba9ca | |||
| 6f21c22d22 | |||
| 1f34442397 | |||
| 373257864f | |||
| b577a69dad | |||
| a64fa9770f | |||
| 0061708785 | |||
| a3b68c2b77 | |||
| 0ac5051aef | |||
| bf419ec3bf | |||
| c6f356fda4 | |||
| 087b8753fb | |||
| 09ec304637 | |||
| f6ab64a8ee | |||
| 4254567bfb | |||
| 9ce46abdce | |||
| 8cce13f6e5 | |||
| f22cbcd26a | |||
| 6f22186b67 | |||
| a231213276 | |||
| b344d806e2 | |||
| 0e3199e289 | |||
| c00eb102ff | |||
| 63b2a1b7a3 | |||
| 5a4156ba04 | |||
| af53b0310f | |||
| ce7d22b26b | |||
| 5f15352401 | |||
| 7da2b3f65d | |||
| 5bf5eeae00 | |||
| c6cbb1d825 | |||
| 3bfb788f42 | |||
| 1c5dfc8f3e | |||
| 4c26aa8d21 | |||
| b67a160e7a | |||
| d8fabdbe11 | |||
| ee8ea9dfda | |||
| ec0f72337d | |||
| 6ae52b6626 | |||
| f4d548d91a | |||
| 18b787dcad | |||
| 8d94abfb1a | |||
| 83e6446b16 | |||
| 88b6e8fc6a | |||
| d4eae2b71e | |||
| 7c756c2f21 | |||
| 5951081efa | |||
| 2e3845b568 | |||
| 290395d38d | |||
| 6d8051e4e6 | |||
| 42dc945ab6 | |||
| 9b0bf3663b | |||
| 981fee5d7f | |||
| 3d15609536 | |||
| a6d9498b39 | |||
| 30228fd267 | |||
| cd255696f0 | |||
| 5eccbebef7 | |||
| bc061dcbc6 | |||
| 7e0e26619f | |||
| edd57011e0 | |||
| 5f6af18ca9 | |||
| 612020e495 | |||
| 08b23f01f8 | |||
| 4bc7901c93 | |||
| c06ff40fd6 | |||
| 3d164898bf | |||
| 86849b67f5 | |||
| 7eb55c21d0 | |||
| c3d9daa1b0 | |||
| fdab4e5caa | |||
| dbad7165bc | |||
| 2eceb07c0b | |||
| 3fbb8bbd52 | |||
| 9684229fc2 | |||
| a7b82ee3be | |||
| 771062ab7f | |||
| b874656eba | |||
| abe17d8c57 | |||
| d02a8a271f | |||
| e29b630405 | |||
| bb4c4c4003 | |||
| b1d523f574 | |||
| c77bdbc3de | |||
| 8036a7cb3c | |||
| 72fb775257 | |||
| 85b696f089 | |||
| ac7c7ad60c | |||
| da5fb285c8 | |||
| 3bfc7a2672 | |||
| 55d172b9a5 | |||
| 2785314e7c | |||
| 139396081b | |||
| f1c58699b0 | |||
| 6f670aa1c4 | |||
| ef78d9ff0d | |||
| 0c53778f99 | |||
| 0fdc904d2d | |||
| f7c499ea6e | |||
| 4a3a95623a | |||
| 0c8ecb2054 | |||
| 99b9f50784 | |||
| 8f90247e98 | |||
| d71de1bd1a | |||
| 5ac3509548 | |||
| c364a38649 | |||
| 468e8674ab | |||
| f7ea6f5d34 | |||
| 20d07f5775 | |||
| 8c68655f9f | |||
| 424411b077 | |||
| e6bbc469b1 | |||
| 98ac7377ac | |||
| 5923bfd7ff | |||
| 7abe3d8cc0 | |||
| b4c6169649 | |||
| 8b83b8c305 | |||
| 3d2be0fd47 | |||
| bbeb9cf701 | |||
| eb0b7a1fec | |||
| 80b96865e7 | |||
| 0867b1fce8 | |||
| da0e9e0725 | |||
| f0148d8855 | |||
| 9cc08e2d91 | |||
| e91898137c | |||
| 921a84304f | |||
| 6ff7a70d11 | |||
| ac32b86b17 | |||
| dbefd2049f | |||
| 50dd03aee7 |
@@ -1,22 +0,0 @@
|
||||
[tool.bumpversion]
|
||||
current_version = "0.2.1"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
serialize = ["{major}.{minor}.{patch}"]
|
||||
search = "{current_version}"
|
||||
replace = "{new_version}"
|
||||
regex = false
|
||||
ignore_missing_version = false
|
||||
ignore_missing_files = false
|
||||
tag = true
|
||||
sign_tags = false
|
||||
tag_name = "v{new_version}"
|
||||
tag_message = "Bump version: {current_version} → {new_version}"
|
||||
allow_dirty = false
|
||||
commit = true
|
||||
message = "Bump version: {current_version} → {new_version}"
|
||||
commit_args = ""
|
||||
setup_hooks = []
|
||||
pre_commit_hooks = []
|
||||
post_commit_hooks = []
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "src/__init__.py"
|
||||
63
.gitea/workflows/docs.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "zensical.toml"
|
||||
jobs:
|
||||
deploy-wiki:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Checkout wiki
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ gitea.repository }}.wiki
|
||||
path: wiki
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Copy docs to wiki
|
||||
run: |
|
||||
# Remove old wiki content (except .git)
|
||||
find wiki -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
|
||||
|
||||
# Copy markdown files maintaining structure
|
||||
cp -r docs/* wiki/
|
||||
|
||||
# Rename index.md to Home.md for wiki homepage
|
||||
if [ -f wiki/index.md ]; then
|
||||
mv wiki/index.md wiki/Home.md
|
||||
fi
|
||||
|
||||
# Flatten folder structure for Gitea wiki compatibility
|
||||
# Move files from subfolders to root with prefixed names
|
||||
for dir in wiki/*/; do
|
||||
if [ -d "$dir" ]; then
|
||||
dirname=$(basename "$dir")
|
||||
for file in "$dir"*.md; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
if [ "$filename" = "index.md" ]; then
|
||||
mv "$file" "wiki/${dirname}.md"
|
||||
else
|
||||
mv "$file" "wiki/${dirname}-${filename}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
rm -rf "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Push to wiki
|
||||
run: |
|
||||
cd wiki
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.local"
|
||||
git add -A
|
||||
git diff --staged --quiet || git commit -m "Update wiki from docs [skip ci]"
|
||||
git push
|
||||
248
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,248 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
github_release:
|
||||
description: "Create Gitea Release"
|
||||
default: true
|
||||
type: boolean
|
||||
prerelease:
|
||||
description: "Is this a prerelease?"
|
||||
default: false
|
||||
type: boolean
|
||||
bump:
|
||||
description: "Bump type"
|
||||
required: false
|
||||
default: "patch"
|
||||
type: choice
|
||||
options:
|
||||
- "major"
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
env:
|
||||
BASE_URL: "http://192.168.178.110:3000"
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.version }}
|
||||
tag: ${{ steps.bump.outputs.tag }}
|
||||
changelog: ${{ steps.build_changelog.outputs.changelog }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
# Uses the version specified in pyproject.toml
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
- name: Bump version (local only)
|
||||
id: bump
|
||||
run: |
|
||||
uv tool install bump-my-version
|
||||
|
||||
uv tool run bump-my-version bump "${{ github.event.inputs.bump }}"
|
||||
version="$(uv tool run bump-my-version show current_version)"
|
||||
|
||||
echo "VERSION=$version" >> "$GITHUB_ENV"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: https://github.com/mikepenz/release-changelog-builder-action@v6.0.1
|
||||
with:
|
||||
platform: "gitea"
|
||||
baseURL: "${{ env.BASE_URL }}"
|
||||
configuration: ".gitea/changelog_config.json"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
build-linux:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAG_NAME: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
- name: Install all dependencies
|
||||
run: uv sync --all-groups
|
||||
|
||||
- name: Build Linux release with Nuitka
|
||||
run: |
|
||||
uv run python -m nuitka \
|
||||
--standalone \
|
||||
--output-dir=dist \
|
||||
--include-data-dir=./config=config \
|
||||
--include-data-dir=./site=site \
|
||||
--include-data-dir=./icons=icons \
|
||||
--include-data-dir=./mail_vorlagen=mail_vorlagen \
|
||||
--enable-plugin=pyside6 \
|
||||
--product-name=SemesterApparatsManager \
|
||||
--product-version=${VERSION} \
|
||||
--output-filename=SAM \
|
||||
main.py
|
||||
|
||||
- name: Prepare Linux Release Artifact
|
||||
run: |
|
||||
mkdir -p releases
|
||||
cd dist/SemesterApparatsManager.dist
|
||||
zip -r "../../releases/SAM-linux-v${VERSION}.zip" *
|
||||
cd ../../
|
||||
|
||||
- name: Create / Update Gitea Release (Linux asset + changelog)
|
||||
if: ${{ github.event.inputs.github_release == 'true' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
name: Release ${{ env.TAG_NAME }}
|
||||
body: ${{ needs.prepare.outputs.changelog }}
|
||||
draft: false
|
||||
prerelease: ${{ github.event.inputs.prerelease }}
|
||||
make_latest: true
|
||||
files: |
|
||||
releases/SAM-linux-v${{ env.VERSION }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
build-windows:
|
||||
needs: [prepare, build-linux]
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAG_NAME: ${{ needs.prepare.outputs.tag }}
|
||||
UV_PATH: 'C:\Users\gitea_runner_windows\.local\bin\uv.exe'
|
||||
UV_NO_PROJECT: "1"
|
||||
UV_NO_CONFIG: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Ensure Python via uv
|
||||
shell: powershell
|
||||
run: |
|
||||
if (-not (Test-Path $env:UV_PATH)) {
|
||||
Write-Error "uv not found at $env:UV_PATH"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$version = "3.12"
|
||||
Write-Host "Checking for Python $version via uv..."
|
||||
$exists = & $env:UV_PATH python list | Select-String $version -Quiet
|
||||
|
||||
if (-not $exists) {
|
||||
Write-Host "Python $version not found; installing with uv..."
|
||||
& $env:UV_PATH python install $version
|
||||
} else {
|
||||
Write-Host "Python $version already installed in uv."
|
||||
}
|
||||
|
||||
- name: Install build dependencies
|
||||
shell: powershell
|
||||
run: |
|
||||
& $env:UV_PATH sync --all-groups
|
||||
|
||||
- name: Build Windows release with Nuitka
|
||||
shell: powershell
|
||||
run: |
|
||||
& $env:UV_PATH run --python 3.12 python -m nuitka `
|
||||
--standalone `
|
||||
--assume-yes-for-downloads `
|
||||
--output-dir=dist `
|
||||
--mingw64 `
|
||||
--include-data-dir=./config=config `
|
||||
--include-data-dir=./site=site `
|
||||
--include-data-dir=./icons=icons `
|
||||
--include-data-dir=./mail_vorlagen=mail_vorlagen `
|
||||
--enable-plugin=pyside6 `
|
||||
--product-name=SemesterApparatsManager `
|
||||
--product-version=${env:VERSION} `
|
||||
--output-filename=SAM.exe `
|
||||
main.py
|
||||
|
||||
- name: Prepare Windows Release Artifact
|
||||
shell: powershell
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path releases | Out-Null
|
||||
Set-Location dist
|
||||
Compress-Archive -Path * -DestinationPath "..\releases\SAM-windows-v${env:VERSION}.zip" -Force
|
||||
Set-Location ..
|
||||
|
||||
- name: Attach Windows asset to Gitea Release
|
||||
if: ${{ github.event.inputs.github_release == 'true' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
draft: false
|
||||
prerelease: ${{ github.event.inputs.prerelease }}
|
||||
files: |
|
||||
releases/SAM-windows-v${{ env.VERSION }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
||||
# GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
finalize:
|
||||
needs: [prepare, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
- name: Set Git identity
|
||||
run: |
|
||||
git config user.name "Gitea CI"
|
||||
git config user.email "ci@git.theprivateserver.de"
|
||||
|
||||
- name: Bump version and push
|
||||
run: |
|
||||
uv tool install bump-my-version
|
||||
uv tool run bump-my-version bump "${{ github.event.inputs.bump }}"
|
||||
|
||||
- name: Push version bump
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ github.ref }}
|
||||
7
.gitignore
vendored
@@ -226,5 +226,8 @@ output
|
||||
|
||||
config.yaml
|
||||
**/tempCodeRunnerFile.py
|
||||
uv.lock
|
||||
uv.lock
|
||||
|
||||
logs/
|
||||
*.pdf
|
||||
*.docx
|
||||
test.py
|
||||
|
||||
7
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9 # latest ruff version
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
- id: ruff-format
|
||||
216
MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Migration Guide: New File Structure
|
||||
|
||||
## Overview
|
||||
|
||||
The codebase has been reorganized to improve clarity, maintainability, and separation of concerns. This guide shows how to update your imports.
|
||||
|
||||
## New Structure Summary
|
||||
|
||||
```
|
||||
src/
|
||||
├── core/ # Domain models & constants (formerly in logic/)
|
||||
├── database/ # Data persistence (formerly in backend/)
|
||||
├── services/ # External APIs (from backend/ and logic/)
|
||||
├── parsers/ # File parsing (formerly in logic/)
|
||||
├── documents/ # Document generation (formerly in utils/)
|
||||
├── background/ # Threading tasks (formerly in backend/)
|
||||
├── admin/ # Admin commands (formerly in backend/)
|
||||
├── shared/ # Cross-cutting concerns (logging, config)
|
||||
├── utils/ # Pure utilities
|
||||
├── ui/ # UI components (unchanged)
|
||||
└── errors/ # Custom exceptions (unchanged)
|
||||
```
|
||||
|
||||
## Import Changes
|
||||
|
||||
### Core Domain Models
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.logic import BookData, Prof, Semester, Apparat
|
||||
from src.logic.dataclass import BookData, Prof
|
||||
from src.logic.semester import Semester
|
||||
from src.logic.constants import APP_NRS, SEMAP_MEDIA_ACCOUNTS
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.core.models import BookData, Prof, Semester, Apparat, ApparatData
|
||||
from src.core import BookData, Prof, Semester # Can use shorthand
|
||||
from src.core.semester import Semester
|
||||
from src.core.constants import APP_NRS, SEMAP_MEDIA_ACCOUNTS
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.backend import Database
|
||||
from src.backend.database import Database
|
||||
from src.backend.db import CREATE_TABLE_MEDIA
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.database import Database
|
||||
from src.database.connection import Database # If you need specific module
|
||||
from src.database.schemas import CREATE_TABLE_MEDIA
|
||||
```
|
||||
|
||||
### External Services & APIs
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.backend.catalogue import Catalogue
|
||||
from src.backend.webadis import get_book_medianr
|
||||
from src.logic.SRU import SWB
|
||||
from src.logic.lehmannsapi import LehmannsClient
|
||||
from src.logic.zotero import ZoteroController
|
||||
from src.logic.webrequest import BibTextTransformer, WebRequest
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.services import Catalogue, SWB, LehmannsClient, ZoteroController
|
||||
from src.services.catalogue import Catalogue
|
||||
from src.services.webadis import get_book_medianr
|
||||
from src.services.sru import SWB
|
||||
from src.services.lehmanns import LehmannsClient
|
||||
from src.services.zotero import ZoteroController
|
||||
from src.services.webrequest import BibTextTransformer, WebRequest
|
||||
```
|
||||
|
||||
### Parsers
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.logic import csv_to_list, word_to_semap
|
||||
from src.logic.csvparser import csv_to_list
|
||||
from src.logic.wordparser import word_to_semap
|
||||
from src.logic.pdfparser import pdf_to_text
|
||||
from src.logic.xmlparser import xml_to_dict
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.parsers import csv_to_list, word_to_semap # Lazy loading
|
||||
from src.parsers.csv_parser import csv_to_list
|
||||
from src.parsers.word_parser import word_to_semap
|
||||
from src.parsers.pdf_parser import pdf_to_text
|
||||
from src.parsers.xml_parser import xml_to_dict
|
||||
```
|
||||
|
||||
### Document Generation
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.utils.richtext import create_document, create_pdf
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.documents import create_document, create_pdf
|
||||
from src.documents.generators import create_document, create_pdf
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.backend import AutoAdder, AvailChecker, BookGrabber
|
||||
from src.backend.threads_autoadder import AutoAdder
|
||||
from src.backend.threads_availchecker import AvailChecker
|
||||
from src.backend.thread_bookgrabber import BookGrabber
|
||||
from src.backend.thread_neweditions import NewEditionCheckerThread
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.background import AutoAdder, AvailChecker, BookGrabber, NewEditionCheckerThread
|
||||
from src.background.autoadder import AutoAdder
|
||||
from src.background.availability_checker import AvailChecker
|
||||
from src.background.book_grabber import BookGrabber
|
||||
from src.background.new_editions import NewEditionCheckerThread
|
||||
```
|
||||
|
||||
### Admin Commands
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.backend import AdminCommands
|
||||
from src.backend.admin_console import AdminCommands
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.admin import AdminCommands
|
||||
from src.admin.commands import AdminCommands
|
||||
```
|
||||
|
||||
### Configuration & Logging
|
||||
|
||||
**OLD:**
|
||||
```python
|
||||
from src.backend.settings import Settings
|
||||
from src.logic.settings import Settings
|
||||
from src.shared.logging import log # This stays the same
|
||||
```
|
||||
|
||||
**NEW:**
|
||||
```python
|
||||
from src.shared import Settings, load_config, log
|
||||
from src.shared.config import Settings, load_config
|
||||
from src.shared.logging import log
|
||||
```
|
||||
|
||||
## File Renames
|
||||
|
||||
| Old Path | New Path |
|
||||
|----------|----------|
|
||||
| `logic/dataclass.py` | `core/models.py` |
|
||||
| `logic/SRU.py` | `services/sru.py` |
|
||||
| `logic/lehmannsapi.py` | `services/lehmanns.py` |
|
||||
| `backend/database.py` | `database/connection.py` |
|
||||
| `backend/db.py` | `database/schemas.py` |
|
||||
| `backend/threads_autoadder.py` | `background/autoadder.py` |
|
||||
| `backend/threads_availchecker.py` | `background/availability_checker.py` |
|
||||
| `backend/thread_bookgrabber.py` | `background/book_grabber.py` |
|
||||
| `backend/thread_neweditions.py` | `background/new_editions.py` |
|
||||
| `backend/admin_console.py` | `admin/commands.py` |
|
||||
| `utils/richtext.py` | `documents/generators.py` |
|
||||
| `logic/csvparser.py` | `parsers/csv_parser.py` |
|
||||
| `logic/pdfparser.py` | `parsers/pdf_parser.py` |
|
||||
| `logic/wordparser.py` | `parsers/word_parser.py` |
|
||||
| `logic/xmlparser.py` | `parsers/xml_parser.py` |
|
||||
|
||||
## Quick Migration Checklist
|
||||
|
||||
1. ✅ Update all `from src.backend import Database` → `from src.database import Database`
|
||||
2. ✅ Update all `from src.logic import BookData` → `from src.core.models import BookData`
|
||||
3. ✅ Update all `from src.backend.catalogue` → `from src.services.catalogue`
|
||||
4. ✅ Update all `from src.logic.SRU` → `from src.services.sru`
|
||||
5. ✅ Update all `from src.backend.admin_console` → `from src.admin`
|
||||
6. ✅ Update threading imports from `src.backend.thread*` → `src.background.*`
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Clearer architecture**: Each folder has a specific, well-defined purpose
|
||||
- **Better dependency flow**: core → database/services → background → ui
|
||||
- **Reduced duplication**: Merged 3 duplicate files (pickles.py, settings.py)
|
||||
- **Easier navigation**: Intuitive folder names ("services" vs "logic")
|
||||
- **Scalability**: Clear extension points for new features
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
The old `backend/` and `logic/` folders still exist with original files. They will be removed in a future cleanup phase after thorough testing.
|
||||
|
||||
## Questions?
|
||||
|
||||
If you encounter import errors:
|
||||
1. Check this guide for the new import path
|
||||
2. Search for the class/function name in the new structure
|
||||
3. Most moves follow the pattern: external APIs → `services/`, data models → `core/`, threads → `background/`
|
||||
|
||||
## Status
|
||||
|
||||
✅ **Migration Complete** - Application successfully starts and runs with new structure!
|
||||
439
README.md
@@ -1,3 +1,438 @@
|
||||
# Semesterapparate
|
||||
# SemesterapparatsManager
|
||||
|
||||
this repo will be used to create a GUI application to manage the semesterapparate of the PH Freiburg.
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](LICENSE)
|
||||
|
||||
A comprehensive desktop application for managing semester course reserve collections (Semesterapparate) at the University of Education Freiburg. This tool streamlines the workflow of creating, managing, and maintaining both physical and digital course reserves, with integrated citation management powered by Zotero.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Development](#development)
|
||||
- [Documentation](#documentation)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
SemesterapparatsManager is a Python-based graphical application designed to simplify the complex workflow of academic course reserve management. It provides librarians and staff with tools to:
|
||||
|
||||
- **Manage Physical Reserves**: Track books, media, and materials reserved for courses
|
||||
- **Handle Digital Collections**: Process, scan, and catalog digital course materials
|
||||
- **Automate Citations**: Generate proper bibliographic citations using Zotero integration
|
||||
- **Communicate**: Send automated emails to professors about reserve status
|
||||
- **Analyze**: View statistics and search through historical data
|
||||
- **Integrate**: Connect with library catalogs (SWB, DNB) and vendor APIs (Lehmanns)
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Framework**: PySide6 (Qt6) for cross-platform GUI
|
||||
- **Database**: SQLite with migration support
|
||||
- **APIs**: Integration with SWB, DNB, Zotero, OpenAI, and catalog services
|
||||
- **Document Processing**: Word, PDF, CSV, and XML parsing
|
||||
- **Bibliography**: Zotero-based citation management
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Course Reserve Management
|
||||
|
||||
- **Create & Edit**: Add new semester apparatus with book and media entries
|
||||
- **Extend Duration**: Extend existing reserves for additional semesters
|
||||
- **Smart Search**: Find reserves by semester, professor, subject, or signature
|
||||
- **Availability Checking**: Automated checks against library catalog
|
||||
- **New Edition Detection**: Background thread to find newer editions of books
|
||||
|
||||
### Digital Collection Features
|
||||
|
||||
- **Document Parsing**: Extract information from submitted Word/PDF forms
|
||||
- **Smart Splitting**: Automatically split multi-part book sections
|
||||
- **Citation Generation**: Create proper citations for all digital files
|
||||
- **ELSA Integration**: Manage electronic semester apparatus (ELSA) workflows
|
||||
- **File Management**: Track and recreate files from database
|
||||
|
||||
### Communication & Notifications
|
||||
|
||||
- **Email Templates**: Pre-configured templates for common scenarios
|
||||
- **Professor Notifications**: Automated emails for creation, extension, or dissolution
|
||||
- **Message System**: Attach messages to specific reserves or broadcast to all
|
||||
|
||||
### Data & Analytics
|
||||
|
||||
- **Statistics Dashboard**: Visualize creation and deletion trends
|
||||
- **Advanced Search**: Multi-criteria search across all reserves
|
||||
- **Export**: Generate reports and documentation
|
||||
- **Calendar View**: Timeline of reserve activities
|
||||
|
||||
### Administration
|
||||
|
||||
- **User Management**: Create, edit, and delete system users
|
||||
- **Professor Database**: Maintain professor contact information
|
||||
- **Settings Configuration**: Customize database paths, temp directories, API keys
|
||||
- **Backup & Migration**: Database migration support for schema updates
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
SemesterapparatsManager/
|
||||
├── src/
|
||||
│ ├── core/ # Domain models & constants
|
||||
│ │ ├── models.py # BookData, Prof, Apparat, Semester, etc.
|
||||
│ │ ├── constants.py # Application constants
|
||||
│ │ └── semester.py # Semester handling logic
|
||||
│ ├── database/ # Data persistence layer
|
||||
│ │ ├── connection.py # Database class & operations
|
||||
│ │ ├── schemas.py # SQL schema definitions
|
||||
│ │ └── migrations/ # SQL migration files
|
||||
│ ├── services/ # External API integrations
|
||||
│ │ ├── catalogue.py # RDS catalog scraping
|
||||
│ │ ├── sru.py # SWB/DNB library API client
|
||||
│ │ ├── lehmanns.py # Lehmanns bookstore API
|
||||
│ │ ├── zotero.py # Zotero integration
|
||||
│ │ ├── webadis.py # WebADIS automation
|
||||
│ │ └── openai.py # OpenAI API integration
|
||||
│ ├── parsers/ # Document & file parsing
|
||||
│ │ ├── csv_parser.py # CSV parsing
|
||||
│ │ ├── word_parser.py # Word document parsing
|
||||
│ │ ├── pdf_parser.py # PDF text extraction
|
||||
│ │ ├── xml_parser.py # XML parsing
|
||||
│ │ └── transformers/ # Bibliography format conversion
|
||||
│ ├── documents/ # Document generation
|
||||
│ │ └── generators.py # Word/PDF document creation
|
||||
│ ├── background/ # Background tasks & threading
|
||||
│ │ ├── autoadder.py # Automatic book addition
|
||||
│ │ ├── availability_checker.py # Catalog availability
|
||||
│ │ ├── book_grabber.py # Catalog metadata retrieval
|
||||
│ │ └── new_editions.py # New edition detection
|
||||
│ ├── ui/ # User interface components
|
||||
│ │ ├── userInterface.py # Main application window
|
||||
│ │ ├── dialogs/ # Dialog windows
|
||||
│ │ └── widgets/ # Reusable UI widgets
|
||||
│ ├── admin/ # Administrative functions
|
||||
│ │ └── commands.py # Admin CLI commands
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ ├── files.py # File operations
|
||||
│ │ ├── sorting.py # Custom sorting logic
|
||||
│ │ └── blob.py # Binary data handling
|
||||
│ ├── shared/ # Cross-cutting concerns
|
||||
│ │ ├── logging.py # Centralized logging
|
||||
│ │ └── config.py # Configuration management
|
||||
│ └── errors/ # Custom exceptions
|
||||
│ └── database.py # Database-specific errors
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
├── mail_vorlagen/ # Email templates
|
||||
├── config.yaml # Application configuration
|
||||
├── main.py # Application entry point
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Architecture Principles
|
||||
|
||||
**Layered Architecture**:
|
||||
```
|
||||
UI Layer (PySide6 Qt Widgets)
|
||||
↓
|
||||
Background Tasks (QThread workers)
|
||||
↓
|
||||
Business Logic (Core models & operations)
|
||||
↓
|
||||
Services Layer (External API integrations)
|
||||
↓
|
||||
Data Access Layer (Database & file operations)
|
||||
```
|
||||
|
||||
**Key Design Patterns**:
|
||||
- **Repository Pattern**: Database class abstracts data persistence
|
||||
- **Service Layer**: External integrations isolated in `services/`
|
||||
- **Observer Pattern**: Qt signals/slots for event-driven updates
|
||||
- **Factory Pattern**: Document and citation generators
|
||||
- **Strategy Pattern**: Multiple parsing strategies for different file formats
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- [uv](https://github.com/astral-sh/uv) - Fast Python package installer and resolver (recommended)
|
||||
```bash
|
||||
# Install uv (Windows PowerShell)
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
|
||||
# Or using pip
|
||||
pip install uv
|
||||
```
|
||||
|
||||
### Setup Steps (Using uv - Recommended)
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/yourusername/SemesterapparatsManager.git
|
||||
cd SemesterapparatsManager
|
||||
```
|
||||
|
||||
2. **Create virtual environment and install dependencies**:
|
||||
```bash
|
||||
# uv automatically creates venv and installs dependencies
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. **Configure application**:
|
||||
- First launch will present a setup wizard
|
||||
- Configure database path, temp directory, and API keys
|
||||
- Create admin user account
|
||||
|
||||
4. **Run the application**:
|
||||
```bash
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
### Alternative Setup (Using pip/venv)
|
||||
|
||||
<details>
|
||||
<summary>Click to expand traditional pip installation steps</summary>
|
||||
|
||||
1. **Create virtual environment**:
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
2. **Activate virtual environment**:
|
||||
- Windows (PowerShell):
|
||||
```powershell
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
- Linux/Mac:
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
3. **Install dependencies**:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Run the application**:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Building Executable
|
||||
|
||||
To build a standalone executable:
|
||||
|
||||
```bash
|
||||
# Using uv
|
||||
uv run pyinstaller --noconfirm --onedir --windowed \
|
||||
--icon='icons/app.ico' \
|
||||
--name='SemesterapparatsManager' \
|
||||
--clean \
|
||||
--add-data='config.yaml;.' \
|
||||
--add-data='icons;icons' \
|
||||
main.py
|
||||
```
|
||||
|
||||
Or use the provided build task (see `pyproject.toml`).
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### First Time Setup
|
||||
|
||||
1. **Launch Application**: Run `python main.py`
|
||||
2. **Setup Wizard**: Configure basic settings
|
||||
- Database location
|
||||
- Temporary files directory
|
||||
- Library catalog credentials (optional)
|
||||
- API keys (Zotero, OpenAI - optional)
|
||||
3. **Create Admin User**: Set up your admin credentials
|
||||
4. **Login**: Use your credentials to access the main interface
|
||||
|
||||
### Creating a Semester Apparatus
|
||||
|
||||
1. **Navigate**: Main window → "Neuer Apparat" (New Apparatus)
|
||||
2. **Fill Details**:
|
||||
- Semester (e.g., WiSe 2024/25)
|
||||
- Professor information
|
||||
- Course subject
|
||||
- Apparatus number
|
||||
3. **Add Books**: Click "Buch hinzufügen" (Add Book)
|
||||
- Enter signature or search by title
|
||||
- System fetches metadata from catalog
|
||||
- Add multiple books as needed
|
||||
4. **Add Media**: Click "Medium hinzufügen" (Add Media)
|
||||
- DVDs, CDs, or other media types
|
||||
5. **Save**: Confirm and create the apparatus
|
||||
6. **Generate Email**: Optionally send notification to professor
|
||||
|
||||
### Managing Digital Collections (ELSA)
|
||||
|
||||
1. **Upload Form**: Submit Word/PDF form with book chapter information
|
||||
### Setting Up Development Environment
|
||||
|
||||
1. **Install all dependencies** (including dev dependencies):
|
||||
```bash
|
||||
# Using uv (recommended)
|
||||
uv sync --all-extras
|
||||
|
||||
# Or using pip
|
||||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
2. **Enable logging**:
|
||||
```python
|
||||
from src.shared.logging import configure
|
||||
configure("DEBUG") # In main.py
|
||||
```
|
||||
|
||||
3. **Run tests**:
|
||||
```bash
|
||||
# Using uv
|
||||
uv run pytest tests/
|
||||
|
||||
# Or with activated venv
|
||||
pytest tests/
|
||||
```ministrative Tasks
|
||||
|
||||
- **User Management**: Admin → Users → Create/Edit/Delete
|
||||
- **Professor Database**: Admin → Professors → Manage contacts
|
||||
- **System Settings**: Edit → Settings → Configure paths and APIs
|
||||
- **Database Maintenance**: Admin → Database → Run migrations
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Setting Up Development Environment
|
||||
|
||||
1. **Install dev dependencies**:
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
2. **Enable logging**:
|
||||
```python
|
||||
from src.shared.logging import configure
|
||||
configure("DEBUG") # In main.py
|
||||
```
|
||||
|
||||
3. **Run tests**:
|
||||
```bash
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
### Project Standards
|
||||
|
||||
- **Code Style**: Follow PEP 8
|
||||
- **Type Hints**: Use type annotations where possible
|
||||
- **Docstrings**: Google-style docstrings for all public functions
|
||||
- **Logging**: Use centralized logger from `src.shared.logging`
|
||||
- **Imports**: Use new structure (see MIGRATION_GUIDE.md)
|
||||
|
||||
### Database Migrations
|
||||
|
||||
To create a new migration:
|
||||
|
||||
1. Create file: `src/database/migrations/V###__description.sql`
|
||||
2. Use sequential numbering (V001, V002, etc.)
|
||||
3. Write idempotent SQL (use `IF NOT EXISTS`)
|
||||
4. Test migration on copy of production database
|
||||
|
||||
Example:
|
||||
```sql
|
||||
-- V003__add_user_preferences.sql
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
theme TEXT DEFAULT 'light',
|
||||
language TEXT DEFAULT 'de',
|
||||
FOREIGN KEY (user_id) REFERENCES user(id)
|
||||
);
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
**New Service Integration**:
|
||||
1. Create module in `src/services/`
|
||||
2. Implement client class with proper error handling
|
||||
3. Add to `src/services/__init__.py`
|
||||
4. Document API requirements
|
||||
|
||||
**New Document Parser**:
|
||||
1. Create module in `src/parsers/`
|
||||
2. Implement parsing function returning core models
|
||||
3. Add to `src/parsers/__init__.py`
|
||||
4. Write unit tests
|
||||
|
||||
**New UI Dialog**:
|
||||
1. Design in Qt Designer (`.ui` file)
|
||||
2. Convert: `pyside6-uic dialog.ui -o dialog_ui.py`
|
||||
3. Create dialog class in `src/ui/dialogs/`
|
||||
4. Connect signals to business logic
|
||||
### Building Documentation
|
||||
|
||||
```bash
|
||||
# Using uv
|
||||
uv run mkdocs build
|
||||
uv run mkdocs serve # View at http://localhost:8000
|
||||
|
||||
# Or with activated venv
|
||||
mkdocs build
|
||||
mkdocs serve
|
||||
```*[API Documentation](docs/)**: Detailed module documentation
|
||||
- **[User Manual](docs/index.md)**: Complete user guide (MkDocs)
|
||||
|
||||
### Building Documentation
|
||||
|
||||
```bash
|
||||
mkdocs build
|
||||
mkdocs serve # View at http://localhost:8000
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please follow these guidelines:
|
||||
|
||||
1. **Fork** the repository
|
||||
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. **Commit** your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. **Push** to the branch (`git push origin feature/amazing-feature`)
|
||||
5. **Open** a Pull Request
|
||||
|
||||
### Code Review Checklist
|
||||
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] All tests pass
|
||||
- [ ] New features have tests
|
||||
- [ ] Documentation is updated
|
||||
- [ ] No sensitive data in commits
|
||||
- [ ] Import paths use new structure
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **University of Education Freiburg**: Project sponsor and primary user
|
||||
- **Qt/PySide6**: Excellent cross-platform GUI framework
|
||||
- **Zotero**: Citation management integration
|
||||
- **SWB/DNB**: Library catalog services
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions, issues, or feature requests:
|
||||
- **Issues**: [Gitea Issues](https://git.theprivateserver.de/PHB/SemesterapparatsManager/issues)
|
||||
- **Email**: alexander.kirchner@ph-freiburg.de
|
||||
- **Documentation**: [Read the Docs](https://semesterapparatsmanager.readthedocs.io)
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
TBD
|
||||
---
|
||||
|
||||
**Built with ❤️ for academic libraries**
|
||||
@@ -1,4 +0,0 @@
|
||||
from src.ui.userInterface import launch_gui as UI
|
||||
|
||||
if __name__ == "__main__":
|
||||
UI() #:des
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"version": "auto-py-to-exe-configuration_v1",
|
||||
"pyinstallerOptions": [
|
||||
{
|
||||
"optionDest": "noconfirm",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"optionDest": "filenames",
|
||||
"value": "C:/Users/aky547/GitHub/SemesterapparatsManager/__main__.py"
|
||||
},
|
||||
{
|
||||
"optionDest": "onefile",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "console",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "icon_file",
|
||||
"value": "C:/Users/aky547/Downloads/VZjRNn1k.ico"
|
||||
},
|
||||
{
|
||||
"optionDest": "name",
|
||||
"value": "SemesterAppMan"
|
||||
},
|
||||
{
|
||||
"optionDest": "clean_build",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"optionDest": "strip",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "noupx",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "disable_windowed_traceback",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "uac_admin",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "uac_uiaccess",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "argv_emulation",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "bootloader_ignore_signals",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"optionDest": "datas",
|
||||
"value": "C:/Users/aky547/GitHub/SemesterapparatsManager/config.yaml;."
|
||||
}
|
||||
],
|
||||
"nonPyinstallerOptions": {
|
||||
"increaseRecursionLimit": true,
|
||||
"manualArguments": ""
|
||||
}
|
||||
}
|
||||
31
build.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
with open(".version", "r") as version_file:
|
||||
version = version_file.read().strip()
|
||||
|
||||
print("Building the project...")
|
||||
print("Cleaning build dir...")
|
||||
# clear dist directory
|
||||
if os.path.exists("dist"):
|
||||
shutil.rmtree("dist")
|
||||
os.makedirs("dist")
|
||||
print("Build directory cleaned.")
|
||||
build = input("Include console in build? (y/n): ").strip().lower()
|
||||
|
||||
|
||||
command = f"uv run python -m nuitka --standalone --output-dir=dist --include-data-dir=./config=config --include-data-dir=./site=site --include-data-dir=./icons=icons --include-data-dir=./mail_vorlagen=mail_vorlagen --enable-plugin=pyside6 --product-name=SemesterApparatsManager --product-version={version} --output-filename=SAM.exe --windows-icon-from-ico=icons/logo.ico"
|
||||
executable = "main.py"
|
||||
|
||||
|
||||
if build == 'y':
|
||||
|
||||
os.system(f"{command} {executable}")
|
||||
else:
|
||||
command += " --windows-console-mode=disable"
|
||||
os.system(f"{command} {executable}")
|
||||
|
||||
# rename main.dist in dist dir to SemesterApparatsManager
|
||||
os.rename("dist/main.dist", "dist/SemesterApparatsManager")
|
||||
|
||||
print("Build complete.")
|
||||
57
config/base_config.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
default_apps: true
|
||||
save_path: .
|
||||
icon_path: icons/
|
||||
openAI:
|
||||
api_key:
|
||||
model:
|
||||
zotero:
|
||||
api_key:
|
||||
library_id:
|
||||
library_type: user
|
||||
database:
|
||||
name: semesterapparate.db
|
||||
path: .
|
||||
temp: ~/AppData/Local/SAM/SemesterApparatsManager/Cache
|
||||
mail:
|
||||
smtp_server:
|
||||
port:
|
||||
sender:
|
||||
printer_mail:
|
||||
user_name:
|
||||
use_user_name: true
|
||||
password:
|
||||
signature:
|
||||
colors:
|
||||
dark: '#6b6160'
|
||||
light: '#000000'
|
||||
warning: '#ff0000'
|
||||
success: '#00ff00'
|
||||
icons:
|
||||
locked: locked.svg
|
||||
logo: logo.ico
|
||||
show_password: visibility_off.svg
|
||||
hide_password: visibility_on.svg
|
||||
settings: settings.svg
|
||||
today: calendar_today.svg
|
||||
save: save.svg
|
||||
edit_note: edit_note.svg
|
||||
warning: warning.svg
|
||||
error: error.svg
|
||||
mail: mail.svg
|
||||
semester: semester.svg
|
||||
template_fail: test_fail.svg
|
||||
offAction: shutdown.svg
|
||||
info: info.svg
|
||||
help: help.svg
|
||||
close: close.svg
|
||||
notification: notification.svg
|
||||
valid_true: check_success.svg
|
||||
valid_false: check_fail.svg
|
||||
edit: edit.svg
|
||||
important_warn: red_warn.svg
|
||||
person: person_add.svg
|
||||
database: database.svg
|
||||
icons: icons.svg
|
||||
api: api.svg
|
||||
print: print.svg
|
||||
db_search: db_search.svg
|
||||
@@ -1,8 +1,19 @@
|
||||
from typing import Optional
|
||||
from typing import Optional, Any, Union
|
||||
from dataclasses import dataclass
|
||||
from omegaconf import OmegaConf, DictConfig
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@dataclass
|
||||
class OpenAI:
|
||||
api_key: str
|
||||
model: str
|
||||
|
||||
def getattr(self, name: str):
|
||||
return getattr(self, name)
|
||||
|
||||
def _setattr(self, name: str, value: Any):
|
||||
setattr(self, name, value)
|
||||
|
||||
@dataclass
|
||||
class Zotero:
|
||||
@@ -10,33 +21,39 @@ class Zotero:
|
||||
library_id: str
|
||||
library_type: str
|
||||
|
||||
def getattr(self, name):
|
||||
def getattr(self, name: str):
|
||||
return getattr(self, name)
|
||||
|
||||
def _setattr(self, name, value):
|
||||
def _setattr(self, name: str, value: Any):
|
||||
setattr(self, name, value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Database:
|
||||
name: str
|
||||
path: str
|
||||
temp: str
|
||||
|
||||
def getattr(self, name):
|
||||
path: Union[str, Path, None]
|
||||
temp: Union[str, Path, None]
|
||||
def getattr(self, name: str):
|
||||
return getattr(self, name)
|
||||
|
||||
def _setattr(self, name, value):
|
||||
def _setattr(self, name: str, value: Any):
|
||||
setattr(self, name, value)
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.path, str):
|
||||
self.path = Path(self.path).expanduser()
|
||||
if isinstance(self.temp, str):
|
||||
self.temp = Path(self.temp).expanduser()
|
||||
|
||||
@dataclass
|
||||
class Mail:
|
||||
smtp_server: str
|
||||
port: int
|
||||
sender: str
|
||||
sender_name: str
|
||||
password: str
|
||||
use_user_name: bool
|
||||
printer_mail: str
|
||||
user_name: str
|
||||
signature: str | None = None
|
||||
empty_signature = """<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
|
||||
@@ -58,13 +75,13 @@ class Mail:
|
||||
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px;
|
||||
margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html>"""
|
||||
|
||||
def getattr(self, name):
|
||||
def getattr(self, name: str):
|
||||
return getattr(self, name)
|
||||
|
||||
def _setattr(self, name, value):
|
||||
def _setattr(self, name: str, value: Any):
|
||||
setattr(self, name, value)
|
||||
|
||||
def setValue(self, **kwargs):
|
||||
def setValue(self, **kwargs: Any):
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
@@ -78,7 +95,7 @@ class Icons:
|
||||
self._colors = None
|
||||
self._icons = None
|
||||
|
||||
def assign(self, key, value):
|
||||
def assign(self, key: str, value: Any):
|
||||
setattr(self, key, value)
|
||||
|
||||
@property
|
||||
@@ -86,7 +103,7 @@ class Icons:
|
||||
return self._path
|
||||
|
||||
@path.setter
|
||||
def path(self, value):
|
||||
def path(self, value: Any):
|
||||
self._path = value
|
||||
|
||||
@property
|
||||
@@ -94,7 +111,7 @@ class Icons:
|
||||
return self._colors
|
||||
|
||||
@colors.setter
|
||||
def colors(self, value):
|
||||
def colors(self, value: Any):
|
||||
self._colors = value
|
||||
|
||||
@property
|
||||
@@ -102,10 +119,10 @@ class Icons:
|
||||
return self._icons
|
||||
|
||||
@icons.setter
|
||||
def icons(self, value):
|
||||
def icons(self, value: Any):
|
||||
self._icons = value
|
||||
|
||||
def get(self, name):
|
||||
def get(self, name: str):
|
||||
return self.icons.get(name)
|
||||
|
||||
|
||||
@@ -120,7 +137,7 @@ class Config:
|
||||
"""
|
||||
|
||||
_config: Optional[DictConfig] = None
|
||||
|
||||
config_exists: bool = True
|
||||
def __init__(self, config_path: str):
|
||||
"""
|
||||
Loads the configuration file and stores it for future access.
|
||||
@@ -132,10 +149,22 @@ class Config:
|
||||
FileNotFoundError: Configuration file not found
|
||||
"""
|
||||
if not os.path.exists(config_path):
|
||||
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
||||
# copy base config file to the given path
|
||||
base = "config/base_config.yaml"
|
||||
if not os.path.exists(base):
|
||||
raise FileNotFoundError(f"Base configuration file not found: {base}")
|
||||
with open(base, "r") as base_file:
|
||||
base_config = base_file.read()
|
||||
with open(config_path, "w") as config_file:
|
||||
config_file.write(base_config)
|
||||
self.config_exists = False
|
||||
self._config = OmegaConf.load(config_path)
|
||||
self.config_path = config_path
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.config_exists
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Saves the current configuration to the file.
|
||||
@@ -145,16 +174,22 @@ class Config:
|
||||
"""
|
||||
OmegaConf.save(self._config, self.config_path)
|
||||
|
||||
def reload(self):
|
||||
"""
|
||||
Reloads the configuration from the file.
|
||||
"""
|
||||
self._config = OmegaConf.load(self.config_path)
|
||||
|
||||
@property
|
||||
def zotero(self):
|
||||
return Zotero(**self._config.zotero)
|
||||
|
||||
@property
|
||||
def zotero_attr(self, name):
|
||||
def zotero_attr(self, name: str):
|
||||
return getattr(self.zotero, name)
|
||||
|
||||
@zotero_attr.setter
|
||||
def zotero_attr(self, name, value):
|
||||
def zotero_attr(self, name: str, value: Any):
|
||||
self.zotero._setattr(name, value)
|
||||
|
||||
@property
|
||||
@@ -162,30 +197,37 @@ class Config:
|
||||
return Database(**self._config.database)
|
||||
|
||||
@property
|
||||
def database_attr(self, name):
|
||||
def database_attr(self, name: str):
|
||||
return getattr(self.database, name)
|
||||
|
||||
@database_attr.setter
|
||||
def database_attr(self, name, value):
|
||||
def database_attr(self, name: str, value: Any):
|
||||
self.database._setattr(name, value)
|
||||
|
||||
@property
|
||||
def openAI(self):
|
||||
return OpenAI(**self._config.openAI)
|
||||
|
||||
@property
|
||||
def mail(self):
|
||||
return Mail(**self._config.mail)
|
||||
|
||||
def mail_attr(self, name):
|
||||
def mail_attr(self, name: str):
|
||||
return getattr(self.mail, name)
|
||||
|
||||
def set_mail_attr(self, name, value):
|
||||
def set_mail_attr(self, name: str, value: Any):
|
||||
OmegaConf.update(self._config, f"mail.{name}", value)
|
||||
|
||||
def set_database_attr(self, name, value):
|
||||
def set_database_attr(self, name: str, value: Any):
|
||||
OmegaConf.update(self._config, f"database.{name}", value)
|
||||
|
||||
def set_zotero_attr(self, name, value):
|
||||
def set_zotero_attr(self, name: str, value: Any):
|
||||
OmegaConf.update(self._config, f"zotero.{name}", value)
|
||||
|
||||
def set_icon_attr(self, name, value):
|
||||
def set_openai_attr(self, name: str, value: Any):
|
||||
OmegaConf.update(self._config, f"openAI.{name}", value)
|
||||
|
||||
def set_icon_attr(self, name: str, value: Any):
|
||||
OmegaConf.update(self._config, f"icons.{name}", value)
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Adminbereich
|
||||
|
||||
Der Adminbereich ist nur freigeschaltet, wenn der angemeldete Nutzer die Rolle admin hat. Hier können neue Nutzer angelegt, bestehende Nutzer bearbeitet oder gelöscht werden. Zusätzlich können die Daten der ProfessorInnen bearbeitet werden.
|
||||
|
||||
Die Verschiedenen Aktionen können über das Dropdown-Menü ausgewählt werden.
|
||||
|
||||
## Nutzer anlegen
|
||||

|
||||
|
||||
Hier kann ein neuer Nutzer angelegt werden. Dazu müssen der Nutzername, das Password und die Rolle angegeben werden. Die Rolle kann frei vergeben, oder aus dem Dropdown ausgewählt werden.
|
||||
|
||||
Über den Knopf **Anlegen** wird der Nutzer angelegt.
|
||||
|
||||
## Nutzer bearbeiten
|
||||

|
||||
|
||||
Hier können die Verschiedenen Nutzer bearbeitet oder gelöscht werden. Hat der ausgewählte Nutzer die Rolle admin, so kann dieser nicht gelöscht werden.
|
||||
Um einen Nutzer zu löschen, muss sowohl ein Haken bei **Löschen** gesetzt werden, als auch der Knopf **Löschen** gedrückt werden.
|
||||
|
||||
## Lehrperson bearbeiten
|
||||
|
||||

|
||||
Hier können die Daten der Lehrperson bearbeitet werden, oder die Lehrperson gelöscht werden. Um eine Lehrperson zu löschen, darf kein Aktiver Apparat vorhanden sein, sowie keine ELSA Aufträge vorhanden sein.
|
||||
|
||||
Um eine Lehrperson zu bearbeiten, muss der Name im Dropdown bei "Alte Angaben" ausgewählt werden. Die Alten Daten werden nun in der Maske angezeigt. Die neuen Daten können nun im unteren Bereich eingegeben werden. Über den Knopf **Aktualisieren** werden die Daten gespeichert.
|
||||
108
docs/allgemein/hauptoberflaeche.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Hauptoberfläche
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Die Hauptoberfläche des SemesterapparatsManager besteht aus drei Hauptbereichen, die über Tabs erreichbar sind:
|
||||
|
||||
## Navigation
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-book-plus:{ .lg .middle } **Anlegen**
|
||||
|
||||
---
|
||||
|
||||
Neue Semesterapparate erstellen, bestehende aktualisieren oder löschen.
|
||||
|
||||
[:octicons-arrow-right-24: Zum Anlegen](../semesterapparat/anlegen.md)
|
||||
|
||||
- :lucide-search:{ .lg .middle } **Suchen/Statistik**
|
||||
|
||||
---
|
||||
|
||||
Semesterapparate suchen, filtern und Statistiken einsehen.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Suche](../semesterapparat/suche.md)
|
||||
|
||||
- :lucide-file-text:{ .lg .middle } **ELSA**
|
||||
|
||||
---
|
||||
|
||||
Elektronische Semesterapparate anlegen und Zitate erstellen.
|
||||
|
||||
[:octicons-arrow-right-24: Zu ELSA](../elsa/anlegen.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Übersichtstabelle
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
In diesem Bereich werden alle erstellten Semesterapparate angezeigt.
|
||||
|
||||
!!! tip "Tipp: Doppelklick"
|
||||
Über einen **Doppelklick** auf einen Apparat werden alle Details geladen und in den Apparatdetails angezeigt.
|
||||
|
||||
### Verfügbare Aktionen
|
||||
|
||||
| Knopf | Funktion |
|
||||
|-------|----------|
|
||||
| :lucide-printer: **Übersicht erstellen** | Erstellt eine druckbare Übersicht der angezeigten Apparate für das Regal |
|
||||
| :lucide-plus: **neu. App anlegen** | Schaltet die Apparatdetails frei für einen neuen Apparat |
|
||||
| :lucide-x: **Auswahl abbrechen** | Entfernt alle Daten und deaktiviert die Apparatdetails |
|
||||
|
||||
!!! note "Hinweis: Übersicht drucken"
|
||||
Die Übersicht wird per Mail an den konfigurierten Drucker geschickt. Vor dem Drucken erfolgt eine Bestätigungsabfrage.
|
||||
|
||||
---
|
||||
|
||||
## Einstellungen
|
||||
|
||||
Die Einstellungen erreichen Sie über das Menü oder das :lucide-settings: Icon.
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
!!! info "Automatisches Wiederherstellen"
|
||||
Die zuletzt geöffnete Seite wird automatisch beim nächsten Start geöffnet.
|
||||
|
||||
### Datenbank
|
||||
|
||||
Hier sind alle Informationen zur Datenbank sowie den temporären Daten hinterlegt.
|
||||
|
||||
!!! warning "Mehrere Nutzer"
|
||||
Sollte die Anwendung von mehreren Nutzern benutzt werden, sollte der Datenbankpfad nur in Absprache geändert werden. Ansonsten kann es zu Synchronisationsproblemen kommen.
|
||||
|
||||
### Zotero
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Konfigurieren Sie hier die Zugangsdaten für Zotero, die für die [ELSA-Zitate](../elsa/zitieren.md) benötigt werden.
|
||||
|
||||
### E-Mail
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
=== "Allgemein"
|
||||
|
||||
Zugangsdaten für den SMTP-Mailversand. Diese werden für Benachrichtigungen an Dozenten benötigt.
|
||||
|
||||
=== "Signatur"
|
||||
|
||||
Die Signatur wird automatisch an jede ausgehende Mail angehängt.
|
||||
|
||||
### Icons
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Übersicht der aktuellen Icons und verfügbaren Farbschemata.
|
||||
|
||||
---
|
||||
|
||||
## Speichern der Einstellungen
|
||||
|
||||
Über den Knopf **Ok** werden die Einstellungen gespeichert.
|
||||
|
||||
!!! success "Sofortige Übernahme"
|
||||
Die meisten Einstellungen werden sofort übernommen. Sollte ein Neustart erforderlich sein, werden Sie darüber informiert.
|
||||
31
docs/allgemein/index.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Allgemein
|
||||
|
||||
Willkommen in der Dokumentation des **SemesterapparatsManager**! In diesem Abschnitt finden Sie alle grundlegenden Informationen zum Programm.
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-info:{ .lg .middle } **Info**
|
||||
|
||||
---
|
||||
|
||||
Erfahren Sie mehr über den SemesterapparatsManager und seine Funktionen.
|
||||
|
||||
[:octicons-arrow-right-24: Mehr erfahren](info.md)
|
||||
|
||||
- :lucide-download:{ .lg .middle } **Installation**
|
||||
|
||||
---
|
||||
|
||||
Installieren Sie den SemesterapparatsManager in wenigen Schritten.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Installation](installation.md)
|
||||
|
||||
- :lucide-layout-dashboard:{ .lg .middle } **Hauptoberfläche**
|
||||
|
||||
---
|
||||
|
||||
Lernen Sie die Benutzeroberfläche des Programms kennen.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Übersicht](hauptoberflaeche.md)
|
||||
|
||||
</div>
|
||||
57
docs/allgemein/info.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Info
|
||||
|
||||
## Über den SemesterapparatsManager
|
||||
|
||||
Der **SemesterapparatsManager** ist ein grafisches Werkzeug zur Verwaltung von Semesterapparaten an der Pädagogischen Hochschule Freiburg.
|
||||
|
||||
!!! abstract "Was ist ein Semesterapparat?"
|
||||
Ein Semesterapparat ist eine Sammlung von Literatur, die von Dozenten für ihre Lehrveranstaltungen zusammengestellt wird. Die Bücher werden in der Bibliothek bereitgestellt und können von Studierenden eingesehen werden.
|
||||
|
||||
## Funktionen
|
||||
|
||||
Die Anwendung ermöglicht eine benutzerfreundliche Verwaltung von physischen und digitalen Semesterapparaten:
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-book-plus:{ .lg .middle } **Anlegen**
|
||||
|
||||
---
|
||||
|
||||
Erstellen Sie neue Semesterapparate mit allen notwendigen Informationen zu Dozenten, Fächern und Literatur.
|
||||
|
||||
- :lucide-search:{ .lg .middle } **Suchen & Statistik**
|
||||
|
||||
---
|
||||
|
||||
Durchsuchen Sie bestehende Apparate und erhalten Sie statistische Auswertungen.
|
||||
|
||||
- :lucide-file-text:{ .lg .middle } **ELSA**
|
||||
|
||||
---
|
||||
|
||||
Verwalten Sie elektronische Semesterapparate (ELSA) mit automatischer Zitat-Erstellung via Zotero.
|
||||
|
||||
- :lucide-mail:{ .lg .middle } **Kommunikation**
|
||||
|
||||
---
|
||||
|
||||
Versenden Sie automatisierte E-Mails an Dozenten bei Erstellung oder Löschung von Apparaten.
|
||||
|
||||
</div>
|
||||
|
||||
## Technische Details
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|-------------|------|
|
||||
| **Programmiersprache** | Python 3.10+ |
|
||||
| **GUI-Framework** | PySide6 (Qt) |
|
||||
| **Datenbank** | SQLite |
|
||||
| **Zitat-System** | Zotero Integration |
|
||||
| **Stil** | DGPs (Deutsche Gesellschaft für Psychologie) |
|
||||
|
||||
## Entwicklung
|
||||
|
||||
Der SemesterapparatsManager wurde entwickelt von **Alexander Kirchner** für die Pädagogische Hochschule Freiburg.
|
||||
|
||||
!!! info "Open Source"
|
||||
Der Quellcode ist auf GitHub verfügbar und kann nach Absprache eingesehen werden.
|
||||
85
docs/allgemein/installation.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Installation
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Bevor Sie den SemesterapparatsManager installieren können, stellen Sie sicher, dass folgende Voraussetzungen erfüllt sind:
|
||||
|
||||
- [x] Windows 10/11 oder höher
|
||||
- [x] Internetzugang für Katalog-Abfragen
|
||||
- [x] Optional: Zotero-Account für ELSA-Funktionen
|
||||
|
||||
## Installation
|
||||
|
||||
### Für Endanwender
|
||||
|
||||
=== "Portable Version"
|
||||
|
||||
1. Laden Sie die neueste Version von der Release-Seite herunter
|
||||
2. Entpacken Sie die ZIP-Datei in einen Ordner Ihrer Wahl
|
||||
3. Starten Sie `SemesterapparatsManager.exe`
|
||||
|
||||
=== "Installer"
|
||||
|
||||
1. Laden Sie den Installer herunter
|
||||
2. Führen Sie die Setup-Datei aus
|
||||
3. Folgen Sie den Anweisungen des Installationsassistenten
|
||||
4. Starten Sie das Programm über das Startmenü
|
||||
|
||||
### Für Entwickler
|
||||
|
||||
!!! note "Entwicklerinstallation"
|
||||
Diese Anleitung ist für Entwickler gedacht, die den Quellcode bearbeiten möchten.
|
||||
|
||||
#### Mit UV (empfohlen)
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://github.com/IHR-REPO/SemesterapparatsManager.git
|
||||
cd SemesterapparatsManager
|
||||
|
||||
# Virtuelle Umgebung erstellen und Abhängigkeiten installieren
|
||||
uv sync
|
||||
|
||||
# Anwendung starten
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
#### Mit pip
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://github.com/IHR-REPO/SemesterapparatsManager.git
|
||||
cd SemesterapparatsManager
|
||||
|
||||
# Virtuelle Umgebung erstellen
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
|
||||
# Abhängigkeiten installieren
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Anwendung starten
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Erster Start
|
||||
|
||||
Beim ersten Start werden Sie aufgefordert, sich anzumelden:
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
!!! tip "Tipp"
|
||||
Ihre Anmeldedaten werden sicher gespeichert. Bei Fragen zur Einrichtung wenden Sie sich an den Administrator.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Nach der Installation sollten Sie die Einstellungen überprüfen:
|
||||
|
||||
1. Öffnen Sie das Programm
|
||||
2. Gehen Sie zu **Einstellungen** (über das Menü oder :lucide-settings:)
|
||||
3. Konfigurieren Sie:
|
||||
- **Datenbank-Pfad**: Speicherort der SQLite-Datenbank
|
||||
- **Zotero**: API-Schlüssel für Zitat-Funktionen
|
||||
- **E-Mail**: SMTP-Einstellungen für Benachrichtigungen
|
||||
|
||||
Weitere Informationen zur Konfiguration finden Sie unter [Hauptoberfläche](hauptoberflaeche.md).
|
||||
86
docs/elsa/anlegen.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# ELSA anlegen
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
---
|
||||
|
||||
## Neuen ELSA erstellen
|
||||
|
||||
### Schritte
|
||||
|
||||
1. Klicken Sie auf **Neuer Auftrag**
|
||||
2. Das Feld *Auftragsdaten* wird freigeschaltet
|
||||
3. Der Fokus wechselt automatisch auf das Feld **Prof.**
|
||||
|
||||
### Professorenauswahl
|
||||
|
||||
Im Feld **Prof.** werden automatisch alle bereits vorhandenen Dozenten aus den Semesterapparaten angezeigt.
|
||||
|
||||
!!! info "Neuer Professor"
|
||||
Wenn der Professor noch keinen Apparat hat, kann der Name manuell eingetragen werden. Es erscheinen zusätzliche Felder:
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
| Feld | Beschreibung | Pflicht |
|
||||
|------|--------------|---------|
|
||||
| **Name** | Name des Dozenten | :material-check: |
|
||||
| **E-Mail** | Kontakt-E-Mail | :material-check: |
|
||||
| **Telefon** | Telefonnummer | :material-check: |
|
||||
|
||||
!!! warning "Pflichtfelder"
|
||||
Solange diese Felder nicht ausgefüllt sind, kann der Auftrag **nicht** gespeichert werden.
|
||||
|
||||
---
|
||||
|
||||
## Schnelleingabe
|
||||
|
||||
Über das Kalender-Icon { .twemoji } können automatisch eingetragen werden:
|
||||
|
||||
- [x] Aktuelles Datum
|
||||
- [x] Aktuelles Semester
|
||||
|
||||
---
|
||||
|
||||
## Dokumente hinzufügen
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
### Formular hinzufügen
|
||||
|
||||
1. Klicken Sie auf **Dokument hinzufügen**
|
||||
2. Wählen Sie die Datei aus
|
||||
3. Das Dokument wird in der Datenbank gespeichert
|
||||
|
||||
### Medien extrahieren
|
||||
|
||||
Über **Medien aus Dokument hinzufügen** werden alle erkannten Medien automatisch in die Tabelle eingetragen.
|
||||
|
||||
!!! tip "Abschnitte trennen"
|
||||
Hat der Professor mehrere Abschnitte in einer Zeile gelistet, aktivieren Sie **Abschnitte trennen** – diese werden dann als separate Einträge hinzugefügt.
|
||||
|
||||
!!! warning "Formatierung im Dokument"
|
||||
Die Abschnitte müssen durch **Semikolon** getrennt sein:
|
||||
|
||||
```
|
||||
1-5; 18-25; 30-35
|
||||
```
|
||||
|
||||
### Automatisches Speichern
|
||||
|
||||
Beim Klicken auf **Medien aus Dokument hinzufügen** wird der Auftrag automatisch gespeichert.
|
||||
|
||||
---
|
||||
|
||||
## Einträge bearbeiten
|
||||
|
||||
Die erkannten Medien erscheinen in der Tabelle und können:
|
||||
|
||||
- :lucide-edit: Bearbeitet werden
|
||||
- :lucide-quote: Zitiert werden → [Zum Zitieren](zitieren.md)
|
||||
- :lucide-trash: Gelöscht werden
|
||||
|
||||
---
|
||||
|
||||
## Dokument öffnen
|
||||
|
||||
Über **Dokument öffnen** kann das hinzugefügte Formular jederzeit eingesehen werden.
|
||||
46
docs/elsa/index.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# ELSA
|
||||
|
||||
**ELSA** steht für **E**lektronischer **L**ehr-**S**emester**a**pparat und ermöglicht die Verwaltung digitaler Literatur für Lehrveranstaltungen.
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-file-plus:{ .lg .middle } **ELSA anlegen**
|
||||
|
||||
---
|
||||
|
||||
Erstellen Sie einen neuen elektronischen Semesterapparat.
|
||||
|
||||
[:octicons-arrow-right-24: ELSA anlegen](anlegen.md)
|
||||
|
||||
- :lucide-quote:{ .lg .middle } **Zitieren**
|
||||
|
||||
---
|
||||
|
||||
Erstellen Sie automatische Zitate für Ihre digitalen Medien.
|
||||
|
||||
[:octicons-arrow-right-24: Zum Zitieren](zitieren.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Was ist ELSA?
|
||||
|
||||
!!! abstract "Definition"
|
||||
Ein elektronischer Semesterapparat (ELSA) ist eine digitale Sammlung von Literatur, die auf Illias bereitgestellt wird. Die Dokumente werden gescannt und mit korrekten Zitationen versehen.
|
||||
|
||||
### Workflow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[ELSA anlegen] --> B[Dokumente hinzufügen]
|
||||
B --> C[Medien aus Dokument extrahieren]
|
||||
C --> D[Einträge zitieren]
|
||||
D --> E[Dateien auf Illias hochladen]
|
||||
```
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- [x] Konfigurierter Zotero-Account
|
||||
- [x] Eingescannte Dokumente oder Formulare
|
||||
- [x] Informationen zum Dozenten und zur Veranstaltung
|
||||
95
docs/elsa/zitieren.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Zitieren
|
||||
|
||||
Da alle gescannten Dokumente später auf **Illias** hochgeladen werden, bietet der SemesterapparatsManager eine automatische Zitierfunktion.
|
||||
|
||||
---
|
||||
|
||||
## Zitierstil
|
||||
|
||||
!!! abstract "Verwendeter Stil"
|
||||
Es wird der Zitierstil der **Deutschen Gesellschaft für Psychologie (DGPs)** verwendet.
|
||||
|
||||
---
|
||||
|
||||
## Oberfläche
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
### Zitat erstellen
|
||||
|
||||
1. Wählen Sie einen Eintrag in der ELSA-Tabelle aus
|
||||
2. Klicken Sie auf **Eintrag zitieren**
|
||||
3. Der Dialog öffnet sich und sucht automatisch im Katalog
|
||||
|
||||
!!! tip "Seitenzahlen"
|
||||
Die angegebene Seitenzahl aus der Tabelle wird automatisch übernommen. Bei mehreren Abschnitten muss die Seitenzahl ggf. angepasst werden.
|
||||
|
||||
---
|
||||
|
||||
## Zitierdialog
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Nach dem Öffnen werden automatisch relevante Daten aus dem Katalog abgefragt und in die Felder eingetragen.
|
||||
|
||||
### Aktionen
|
||||
|
||||
| Knopf | Funktion |
|
||||
|-------|----------|
|
||||
| :lucide-search: **Suchen** | Sucht im Katalog nach dem Identifikator |
|
||||
| :lucide-quote: **Zitat erstellen** | Verbindet mit Zotero und erstellt das Zitat |
|
||||
| :lucide-check: **Ok** | Schließt den Dialog |
|
||||
| :lucide-trash: **Discard** | Entfernt alle Eingaben |
|
||||
| :lucide-x: **Abbrechen** | Schließt den Dialog |
|
||||
| :lucide-rotate-ccw: **Wiederholen** | Zurück zur Eingabe für erneute Suche |
|
||||
|
||||
---
|
||||
|
||||
## Zitat-Erstellung
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Benutzer
|
||||
participant S as SemesterapparatsManager
|
||||
participant Z as Zotero
|
||||
|
||||
U->>S: Klick "Zitat erstellen"
|
||||
S->>Z: Werk anlegen
|
||||
Z-->>S: Werk-ID
|
||||
S->>Z: Zitat abrufen
|
||||
Z-->>S: Formatiertes Zitat
|
||||
S->>U: Zitat anzeigen
|
||||
```
|
||||
|
||||
### Ablauf
|
||||
|
||||
1. Klicken Sie auf **Zitat erstellen**
|
||||
2. Eine Verbindung zu Zotero wird hergestellt
|
||||
3. Das Werk wird in Ihrer Zotero-Bibliothek angelegt
|
||||
4. Das formatierte Zitat wird zurückgegeben
|
||||
|
||||
---
|
||||
|
||||
## Generierte Zitate
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Nach erfolgreicher Erstellung wechselt der Dialog automatisch zur Zitat-Ansicht.
|
||||
|
||||
### Verwendung
|
||||
|
||||
Die generierten Zitate können:
|
||||
|
||||
- :lucide-copy: In die Zwischenablage kopiert werden
|
||||
- :lucide-file-text: Als Dateiname für das gescannte Dokument verwendet werden
|
||||
- :lucide-upload: Zusammen mit dem Dokument auf Illias hochgeladen werden
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
!!! warning "Zotero-Konfiguration erforderlich"
|
||||
Für die Zitierfunktion muss Zotero konfiguriert sein:
|
||||
|
||||
1. Erstellen Sie einen API-Key in Ihrem Zotero-Account
|
||||
2. Tragen Sie den Key in den [Einstellungen](../allgemein/hauptoberflaeche.md#zotero) ein
|
||||
BIN
docs/images/statistics.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -1,3 +1,93 @@
|
||||
# ToC
|
||||
# SemesterapparatsManager
|
||||
|
||||
>TBD
|
||||
Willkommen zur Dokumentation des **SemesterapparatsManager**!
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Der SemesterapparatsManager ist ein grafisches Werkzeug zur Verwaltung von Semesterapparaten an der Pädagogischen Hochschule Freiburg. Die Anwendung ermöglicht eine benutzerfreundliche Verwaltung von physischen und digitalen Semesterapparaten.
|
||||
|
||||
---
|
||||
|
||||
## :lucide-rocket: Schnellstart
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-download:{ .lg .middle } **Installation**
|
||||
|
||||
---
|
||||
|
||||
Installieren Sie den SemesterapparatsManager in wenigen Schritten.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Installation](allgemein/installation.md)
|
||||
|
||||
- :lucide-layout-dashboard:{ .lg .middle } **Hauptoberfläche**
|
||||
|
||||
---
|
||||
|
||||
Lernen Sie die Benutzeroberfläche kennen.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Übersicht](allgemein/hauptoberflaeche.md)
|
||||
|
||||
- :lucide-book-open:{ .lg .middle } **Semesterapparat anlegen**
|
||||
|
||||
---
|
||||
|
||||
Erstellen Sie Ihren ersten Semesterapparat.
|
||||
|
||||
[:octicons-arrow-right-24: Zum Tutorial](semesterapparat/anlegen.md)
|
||||
|
||||
- :lucide-file-text:{ .lg .middle } **ELSA**
|
||||
|
||||
---
|
||||
|
||||
Elektronische Semesterapparate verwalten.
|
||||
|
||||
[:octicons-arrow-right-24: Zu ELSA](elsa/anlegen.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## :lucide-folder-tree: Dokumentationsstruktur
|
||||
|
||||
Diese Dokumentation ist in folgende Bereiche unterteilt:
|
||||
|
||||
| Bereich | Beschreibung |
|
||||
|---------|--------------|
|
||||
| **[Allgemein](allgemein/index.md)** | Grundlegende Informationen, Installation und Übersicht der Hauptoberfläche |
|
||||
| **[Semesterapparat](semesterapparat/index.md)** | Anlegen, Verlängern, Löschen und Suchen von Semesterapparaten |
|
||||
| **[ELSA](elsa/index.md)** | Elektronische Semesterapparate anlegen und Zitate erstellen |
|
||||
| **[Sonstiges](sonstiges/index.md)** | Zusätzliche Informationen und Bildmaterial |
|
||||
|
||||
---
|
||||
|
||||
## :lucide-sparkles: Features
|
||||
|
||||
<div class="grid" markdown>
|
||||
|
||||
!!! success "Automatisierung"
|
||||
- Automatisches Auslesen von Antragsformularen
|
||||
- Automatische Zitat-Erstellung via Zotero
|
||||
- E-Mail-Benachrichtigungen an Dozenten
|
||||
|
||||
!!! info "Verwaltung"
|
||||
- Zentrale Datenbank für alle Apparate
|
||||
- Statistiken und Auswertungen
|
||||
- Such- und Filterfunktionen
|
||||
|
||||
!!! tip "Integration"
|
||||
- Anbindung an den Bibliothekskatalog
|
||||
- Zotero-Integration für Zitate
|
||||
- Mail-System für Kommunikation
|
||||
|
||||
!!! example "Benutzerfreundlich"
|
||||
- Intuitive grafische Oberfläche
|
||||
- Keyboard-Shortcuts
|
||||
- Kontextmenüs für schnellen Zugriff
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
!!! tip "Tipp: Suche"
|
||||
Nutzen Sie die **Suchfunktion** oben rechts ++ctrl+k++, um schnell zu den gewünschten Informationen zu gelangen.
|
||||
|
||||
143
docs/semesterapparat/anlegen.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Semesterapparat anlegen
|
||||
|
||||
Um einen neuen Semesterapparat anzulegen, muss zuerst der Knopf **neu. App anlegen** gedrückt werden. Das Feld der Apparatdetails wird nun zum Bearbeiten entsperrt.
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
---
|
||||
|
||||
## Apparatdetails
|
||||
|
||||
### Pflichtfelder
|
||||
|
||||
Um einen Apparat erfolgreich anzulegen, müssen alle Felder mit einem **Sternchen (*)** ausgefüllt werden. Ist eine Eingabe nicht valide, wird neben der Eingabe ein :material-close-circle:{ style="color: red" } angezeigt.
|
||||
|
||||
!!! warning "Formatierungsanforderungen"
|
||||
Folgende Felder haben spezielle Formatierungsanforderungen:
|
||||
|
||||
| Feld | Format | Beispiel |
|
||||
|------|--------|----------|
|
||||
| **Prof. Name** | Nachname, Vorname | `Müller, Hans` |
|
||||
| **Mail** | Gültige E-Mail-Adresse | `mueller@ph-freiburg.de` |
|
||||
| **Tel** | Mindestens 3 Ziffern | `0761-12345` |
|
||||
| **Semester** | Automatisch berechnet | siehe unten |
|
||||
|
||||
### Semester-Logik
|
||||
|
||||
Das Semester wird automatisch wie folgt berechnet:
|
||||
|
||||
=== "Wintersemester"
|
||||
|
||||
- Checkbox **Winter** aktivieren
|
||||
- **Jahr**:
|
||||
- Oktober–Dezember → aktuelles Jahr
|
||||
- Januar–September → aktuelles Jahr - 1
|
||||
|
||||
=== "Sommersemester"
|
||||
|
||||
- Checkbox **Sommer** aktivieren
|
||||
- **Jahr**: aktuelles Jahr (März–August)
|
||||
|
||||
### Fehlermeldungen
|
||||
|
||||
Beim Versuch, den Apparat zu speichern, bevor alle Felder korrekt ausgefüllt sind, erscheint eine Fehlermeldung:
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
!!! tip "Tipp"
|
||||
Über **Ok** oder **Cancel** wird die Meldung geschlossen und der Apparat kann weiter bearbeitet werden.
|
||||
|
||||
---
|
||||
|
||||
## Dokumente hinzufügen
|
||||
|
||||
Dem Apparat können Dokumente hinzugefügt werden. Besonders hilfreich ist das Hinzufügen der **Antragsformulare**, da der SemesterapparatsManager diese automatisch auslesen kann.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Dokument hinzufügen] --> B{Antragsformular?}
|
||||
B -->|Ja| C[Medien aus Dokument hinzufügen]
|
||||
B -->|Nein| D[Als Referenz speichern]
|
||||
C --> E[Medien werden automatisch erkannt]
|
||||
```
|
||||
|
||||
1. Klicken Sie auf **Dokumente hinzufügen**
|
||||
2. Wählen Sie die gewünschte(n) Datei(en) aus
|
||||
3. Bei Antragsformularen: Klicken Sie auf **Medien aus Dokument hinzufügen**
|
||||
|
||||
!!! warning "Wichtig: Automatisches Speichern"
|
||||
Beim Klicken auf **Medien aus Dokument hinzufügen** wird der Apparat automatisch gespeichert. Dies ist erforderlich, da Medien nur einem existierenden Apparat hinzugefügt werden können.
|
||||
|
||||
---
|
||||
|
||||
## Medienliste
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
In der Medienliste werden alle dem Apparat zugeordneten Medien angezeigt.
|
||||
|
||||
### Optionen
|
||||
|
||||
| Option | Beschreibung |
|
||||
|--------|--------------|
|
||||
| :lucide-eye: **gel. Medien anzeigen** | Zeigt auch gelöschte Medien an |
|
||||
| :lucide-check-circle: **im Apparat?** | Prüft, ob ausgewählte Medien dem Apparat hinzugefügt wurden |
|
||||
| :lucide-plus: **Medien hinzufügen** | Manuelle Eingabe von Signaturen |
|
||||
|
||||
### Kontextmenü
|
||||
|
||||
Mit einem **Rechtsklick** auf ein Medium öffnet sich das Kontextmenü:
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
=== "Allgemeines"
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
- **Bearbeiten**: Metadaten einsehen/bearbeiten
|
||||
- **Löschen**: Medium aus dem Apparat entfernen (nicht physisch löschen)
|
||||
|
||||
=== "Apparate"
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
- **Zum Apparat hinzufügen**: *(derzeit deaktiviert)*
|
||||
- **In Apparat verschieben**: Medium in anderen Apparat verschieben
|
||||
- **In Apparat kopieren**: Medium in anderen Apparat kopieren
|
||||
|
||||
!!! tip "Mehrfachauswahl"
|
||||
Mit ++ctrl+left-button++ können mehrere Medien ausgewählt werden.
|
||||
|
||||
---
|
||||
|
||||
## Medien manuell hinzufügen
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
### Eingabe
|
||||
|
||||
Geben Sie jede Signatur in eine neue Zeile ein und bestätigen Sie mit ++enter++.
|
||||
|
||||
### Optionen
|
||||
|
||||
| Option | Beschreibung |
|
||||
|--------|--------------|
|
||||
| **Modus** | Wählt die Metadatenquelle |
|
||||
| **Jedes Buch verwenden** | Erlaubt das Hinzufügen von Medien, die noch nicht im Apparat sind |
|
||||
| **Exakte Signatur** | Nur diese spezifische Signatur verwenden (kein alternatives Exemplar) |
|
||||
|
||||
!!! info "Signatursuche"
|
||||
Ohne **Exakte Signatur** wird nach der ähnlichsten Signatur gesucht – das gefundene Buch ist dasselbe, aber möglicherweise ein anderes Exemplar.
|
||||
|
||||
---
|
||||
|
||||
## Apparat speichern
|
||||
|
||||
Nach dem Ausfüllen aller Pflichtfelder:
|
||||
|
||||
1. Klicken Sie auf **Speichern**
|
||||
2. Der Apparat wird in der Datenbank gespeichert
|
||||
3. Optional: Bei aktiviertem **Mail senden** öffnet sich der Mail-Dialog
|
||||
|
||||
!!! note "E-Mail-Versand"
|
||||
Der E-Mail-Versand erfordert konfigurierte [Mail-Zugangsdaten](../allgemein/hauptoberflaeche.md#e-mail).
|
||||
47
docs/semesterapparat/index.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Semesterapparat
|
||||
|
||||
In diesem Abschnitt finden Sie alle Informationen zur Verwaltung von Semesterapparaten.
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-book-plus:{ .lg .middle } **Anlegen**
|
||||
|
||||
---
|
||||
|
||||
Erstellen Sie neue Semesterapparate mit allen erforderlichen Informationen.
|
||||
|
||||
[:octicons-arrow-right-24: Apparat anlegen](anlegen.md)
|
||||
|
||||
- :lucide-calendar-plus:{ .lg .middle } **Verlängern**
|
||||
|
||||
---
|
||||
|
||||
Verlängern Sie bestehende Apparate für ein weiteres Semester.
|
||||
|
||||
[:octicons-arrow-right-24: Apparat verlängern](verlaengern.md)
|
||||
|
||||
- :lucide-trash-2:{ .lg .middle } **Löschen**
|
||||
|
||||
---
|
||||
|
||||
Entfernen Sie nicht mehr benötigte Semesterapparate.
|
||||
|
||||
[:octicons-arrow-right-24: Apparat löschen](loeschen.md)
|
||||
|
||||
- :lucide-bar-chart-2:{ .lg .middle } **Statistik**
|
||||
|
||||
---
|
||||
|
||||
Erhalten Sie Einblicke in die Nutzung der Semesterapparate.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Statistik](statistik.md)
|
||||
|
||||
- :lucide-search:{ .lg .middle } **Suche**
|
||||
|
||||
---
|
||||
|
||||
Durchsuchen Sie bestehende Apparate und Medien.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Suche](suche.md)
|
||||
|
||||
</div>
|
||||
63
docs/semesterapparat/loeschen.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Semesterapparat löschen
|
||||
|
||||
Das Löschen von Semesterapparaten erfolgt über die [Suche & Statistik](suche.md) Seite.
|
||||
|
||||
---
|
||||
|
||||
## Löschvorgang
|
||||
|
||||
### Einzelnen Apparat löschen
|
||||
|
||||
1. Navigieren Sie zu **Suchen/Statistik**
|
||||
2. Suchen Sie den gewünschten Apparat
|
||||
3. Aktivieren Sie die Checkbox des Apparats
|
||||
4. Klicken Sie auf **Ausgewählte Löschen**
|
||||
|
||||
### Mehrere Apparate löschen
|
||||
|
||||
1. Verwenden Sie die Suche mit dem Filter **Löschbar**
|
||||
2. Wählen Sie alle zu löschenden Apparate aus
|
||||
3. Klicken Sie auf **Ausgewählte Löschen**
|
||||
|
||||
!!! tip "Filter: Löschbar"
|
||||
Der Filter **Löschbar** zeigt alle Apparate an, deren Endsemester abgelaufen ist und die zur Löschung vorgemerkt werden können.
|
||||
|
||||
---
|
||||
|
||||
## Bestätigung
|
||||
|
||||
Nach dem Klicken auf **Ausgewählte Löschen**:
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Die gelöschten Apparate werden in der Tabelle mit **rotem Hintergrund** angezeigt.
|
||||
|
||||
!!! failure "Gelöschte Apparate"
|
||||
Gelöschte Apparate verbleiben in der Datenbank, werden aber als inaktiv markiert. Sie können bei Bedarf wiederhergestellt werden.
|
||||
|
||||
---
|
||||
|
||||
## Dozenten benachrichtigen
|
||||
|
||||
Bei der Löschung kann eine Benachrichtigung an den Dozenten versendet werden:
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
1. Wählen Sie die zu löschenden Apparate aus
|
||||
2. Klicken Sie auf **Ausgewählte Benachrichtigen**
|
||||
3. Konfigurieren Sie die Anrede
|
||||
4. Versenden Sie die Mail
|
||||
|
||||
Weitere Informationen: [Mails](../allgemein/hauptoberflaeche.md#e-mail)
|
||||
|
||||
---
|
||||
|
||||
## Wiederherstellen
|
||||
|
||||
Gelöschte Apparate können wiederhergestellt werden:
|
||||
|
||||
1. Suchen Sie den gelöschten Apparat (rot markiert)
|
||||
2. Rechtsklick → **Wiederherstellen**
|
||||
|
||||
!!! success "Mehrfachwiederherstellung"
|
||||
Mit ++ctrl+left-button++ können mehrere Apparate gleichzeitig ausgewählt und wiederhergestellt werden.
|
||||
86
docs/semesterapparat/statistik.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Statistik
|
||||
|
||||
Die Statistikfunktion bietet einen Überblick über alle Semesterapparate und deren Entwicklung über die Zeit.
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
---
|
||||
|
||||
## Apparatsstatistik
|
||||
|
||||
Die Statistik zeigt alle Semester an, in denen Apparate erstellt oder gelöscht wurden.
|
||||
|
||||
### Tabellenansicht
|
||||
|
||||
| Spalte | Beschreibung |
|
||||
|--------|--------------|
|
||||
| **Semester** | Das betroffene Semester |
|
||||
| **Erstellt** | Anzahl erstellter Apparate |
|
||||
| **Gelöscht** | Anzahl gelöschter Apparate |
|
||||
|
||||
!!! tip "Detailansicht"
|
||||
Mit einem **Doppelklick** auf ein Semester werden die einzelnen Apparate angezeigt.
|
||||
|
||||
### Detaillierte Ansicht
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Die Detailansicht zeigt:
|
||||
|
||||
- Alle Personen, die in diesem Semester Apparate erstellt/gelöscht haben
|
||||
- Pro Person: Liste aller erstellten oder gelöschten Apparate
|
||||
|
||||
??? info "Navigation"
|
||||
- Klick auf :material-chevron-right: zeigt die Apparate einer Person
|
||||
- Doppelklick auf einen Apparat wechselt zur [Hauptoberfläche](../allgemein/hauptoberflaeche.md)
|
||||
|
||||
!!! warning "Gelöschte Apparate"
|
||||
Gelöschte Apparate können nicht angezeigt werden – die Doppelklick-Funktion ist dort deaktiviert.
|
||||
|
||||
---
|
||||
|
||||
## Diagramm
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Das Liniendiagramm visualisiert:
|
||||
|
||||
- :material-chart-line:{ style="color: green" } **Grün**: Erstellte Apparate
|
||||
- :material-chart-line:{ style="color: red" } **Rot**: Gelöschte Apparate
|
||||
|
||||
!!! tip "Interaktivität"
|
||||
Hovern Sie über Datenpunkte für genaue Werte.
|
||||
|
||||
---
|
||||
|
||||
## Auswertungen
|
||||
|
||||
### Typische Fragestellungen
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-trending-up: **Wachstum**
|
||||
|
||||
---
|
||||
|
||||
Wie viele Apparate wurden pro Semester erstellt?
|
||||
|
||||
- :lucide-users: **Nutzung**
|
||||
|
||||
---
|
||||
|
||||
Welche Dozenten nutzen den Service am meisten?
|
||||
|
||||
- :lucide-calendar: **Saisonalität**
|
||||
|
||||
---
|
||||
|
||||
Gibt es Unterschiede zwischen Sommer- und Wintersemester?
|
||||
|
||||
- :lucide-archive: **Bereinigung**
|
||||
|
||||
---
|
||||
|
||||
Wie viele Apparate werden regelmäßig gelöscht?
|
||||
|
||||
</div>
|
||||
109
docs/semesterapparat/suche.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Suche
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Die Suchseite bietet zwei Hauptfunktionen: die **Apparatsuche** und die **Mediensuche**.
|
||||
|
||||
---
|
||||
|
||||
## Apparatsuche
|
||||
|
||||
### Suchfilter
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
| Filter | Beschreibung |
|
||||
|--------|--------------|
|
||||
| **AppNr** | Nummer des Semesterapparates (Dropdown mit allen belegten Nummern) |
|
||||
| **Person** | Name des Dozenten |
|
||||
| **Fach** | Fachrichtung des Apparates |
|
||||
| **Erstell-Semester** | Semester der Erstellung |
|
||||
| **End-Semester** | Geplantes Ende des Apparates |
|
||||
| **Dauerapp** | Nur Dauerapparate anzeigen |
|
||||
| **Löschbar** | Alle löschbaren Apparate (überschreibt andere Filter) |
|
||||
|
||||
!!! tip "Alle anzeigen"
|
||||
Starten Sie die Suche ohne Eingabe, um **alle** Semesterapparate anzuzeigen.
|
||||
|
||||
---
|
||||
|
||||
## Suchergebnisse
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
### Ergebnistabelle
|
||||
|
||||
Für jeden Treffer wird angezeigt:
|
||||
|
||||
- :material-checkbox-marked-outline: Checkbox zur Auswahl
|
||||
- **Apparatsname**
|
||||
- **Apparatsnummer**
|
||||
- **Person**
|
||||
- **Fach**
|
||||
|
||||
!!! failure "Gelöschte Apparate"
|
||||
Gelöschte Apparate werden mit **rotem Hintergrund** angezeigt.
|
||||
|
||||
### Slider
|
||||
|
||||
!!! info "Layout anpassen"
|
||||
Der vertikale Slider zwischen Tabelle und Graph kann verschoben werden, um mehr Platz für einen der Bereiche zu schaffen.
|
||||
|
||||
---
|
||||
|
||||
## Aktionen
|
||||
|
||||
### Ausgewählte Löschen
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
1. Aktivieren Sie die Checkboxen der zu löschenden Apparate
|
||||
2. Klicken Sie auf **Ausgewählte Löschen**
|
||||
3. Gelöschte Apparate werden rot markiert
|
||||
|
||||
Weitere Informationen: [Apparat löschen](loeschen.md)
|
||||
|
||||
### Ausgewählte Benachrichtigen
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Versendet eine E-Mail an die Dozenten der ausgewählten Apparate.
|
||||
|
||||
1. Wählen Sie die Apparate aus
|
||||
2. Klicken Sie auf **Ausgewählte Benachrichtigen**
|
||||
3. Konfigurieren Sie die Anrede
|
||||
4. Versenden Sie die Mail
|
||||
|
||||
---
|
||||
|
||||
## Kontextmenü
|
||||
|
||||
Rechtsklick auf einen Apparat öffnet das Kontextmenü:
|
||||
|
||||
| Option | Beschreibung |
|
||||
|--------|--------------|
|
||||
| :lucide-calendar-plus: **Verlängern** | Öffnet den [Verlängerungsdialog](verlaengern.md) |
|
||||
| :lucide-undo: **Wiederherstellen** | Stellt gelöschte Apparate wieder her |
|
||||
|
||||
!!! tip "Mehrfachauswahl"
|
||||
Mit ++ctrl+left-button++ auf die Zeilennummer können mehrere Apparate für die Wiederherstellung ausgewählt werden.
|
||||
|
||||
---
|
||||
|
||||
## Mediensuche
|
||||
|
||||
Der **Suchbereich** prüft, ob ein Exemplar in einem Apparat vorhanden ist.
|
||||
|
||||
### Suchkriterien
|
||||
|
||||
| Kriterium | Beschreibung |
|
||||
|-----------|--------------|
|
||||
| **Titel** | Titel des Exemplars (automatische Trunkierung) |
|
||||
| **Signatur** | Signatur des Exemplars (automatische Trunkierung) |
|
||||
|
||||
!!! warning "Pflichtfelder"
|
||||
Im Gegensatz zur Apparatsuche kann hier **keine** leere Suche durchgeführt werden, da zu viele Ergebnisse möglich wären.
|
||||
|
||||
### Ergebnisse
|
||||
|
||||
Die gefundenen Exemplare werden mit den zugehörigen Apparaten angezeigt.
|
||||
44
docs/semesterapparat/verlaengern.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Semesterapparat verlängern
|
||||
|
||||
Ein Dialog zum Verlängern eines Semesterapparates für ein weiteres Semester.
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
---
|
||||
|
||||
## Verlängerung durchführen
|
||||
|
||||
### Schritte
|
||||
|
||||
1. Wählen Sie den zu verlängernden Apparat in der [Suche](suche.md) oder [Statistik](statistik.md) aus
|
||||
2. Öffnen Sie den Verlängerungsdialog via Rechtsklick → **Verlängern**
|
||||
3. Wählen Sie das Zielsemester aus
|
||||
4. Klicken Sie auf **Speichern**
|
||||
|
||||
### Eingabefelder
|
||||
|
||||
| Feld | Beschreibung |
|
||||
|------|--------------|
|
||||
| **Semester** | Sommer- oder Wintersemester auswählen |
|
||||
| **Jahr** | Das Jahr des neuen Semesters |
|
||||
| **Dauerapparat** | :lucide-check: Markiert den Apparat als Dauerapparat |
|
||||
|
||||
---
|
||||
|
||||
## Dauerapparat
|
||||
|
||||
!!! info "Was ist ein Dauerapparat?"
|
||||
Ein Dauerapparat wird nicht automatisch zur Löschung vorgemerkt und bleibt aktiv, bis er manuell gelöscht wird.
|
||||
|
||||
!!! warning "Hinweis"
|
||||
Damit der Apparat als Dauerapparat verlängert werden kann, **muss** trotzdem ein Semester angegeben werden.
|
||||
|
||||
---
|
||||
|
||||
## Nach der Verlängerung
|
||||
|
||||
Nach dem Speichern:
|
||||
|
||||
- [x] Das Endsemester wird automatisch aktualisiert
|
||||
- [x] Die Änderung erscheint in allen relevanten Tabellen
|
||||
- [x] Optional: Benachrichtigung an den Dozenten versenden
|
||||
51
docs/sonstiges/bilder.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Bilder
|
||||
|
||||
Eine Sammlung von Screenshots und Referenzbildern aus dem SemesterapparatsManager.
|
||||
|
||||
---
|
||||
|
||||
## Admin-Bereich
|
||||
|
||||
### Admin-Aktionen
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Die Admin-Aktionen ermöglichen administrative Aufgaben wie Datenbankwartung und Systemkonfiguration.
|
||||
|
||||
---
|
||||
|
||||
## Semesterapparat
|
||||
|
||||
### Apparatscheckliste
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Die Checkliste zeigt den aktuellen Status eines Semesterapparates an.
|
||||
|
||||
### Medien hinzufügen
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Dialog zum manuellen Hinzufügen von Medien über ihre Signaturen.
|
||||
|
||||
---
|
||||
|
||||
## Eingabehilfen
|
||||
|
||||
### Kalender
|
||||
|
||||
{ loading=lazy }
|
||||
|
||||
Der Kalender ermöglicht die schnelle Auswahl von Datum und Semester.
|
||||
|
||||
---
|
||||
|
||||
## Weitere Screenshots
|
||||
|
||||
!!! tip "Screenshots in der Dokumentation"
|
||||
Detaillierte Screenshots finden Sie in den jeweiligen Abschnitten:
|
||||
|
||||
- [Hauptoberfläche](../allgemein/hauptoberflaeche.md) – Übersicht der Benutzeroberfläche
|
||||
- [Semesterapparat anlegen](../semesterapparat/anlegen.md) – Erstellung von Apparaten
|
||||
- [ELSA anlegen](../elsa/anlegen.md) – Elektronische Semesterapparate
|
||||
- [Zitieren](../elsa/zitieren.md) – Zitat-Dialog
|
||||
15
docs/sonstiges/index.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Sonstiges
|
||||
|
||||
Zusätzliche Informationen und Ressourcen zum SemesterapparatsManager.
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :lucide-image:{ .lg .middle } **Bilder**
|
||||
|
||||
---
|
||||
|
||||
Bildergalerie mit Screenshots und Referenzbildern.
|
||||
|
||||
[:octicons-arrow-right-24: Zur Galerie](bilder.md)
|
||||
|
||||
</div>
|
||||
1
icons/db_search.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M472-120q-73-1-137.5-13.5t-112-34Q175-189 147.5-218T120-280q0 33 27.5 62t75 50.5q47.5 21.5 112 34T472-120Zm-71-204q-30-3-58-8t-53.5-12q-25.5-7-48-15.5T200-379q19 11 41.5 19.5t48 15.5q25.5 7 53.5 12t58 8Zm79-275q86 0 177.5-26T760-679q-11-29-100.5-55T480-760q-91 0-178.5 25.5T200-679q15 29 104.5 54.5T480-599Zm-61 396q10 23 23 44t30 39q-73-1-137.5-13.5t-112-34Q175-189 147.5-218T120-280v-400q0-33 28.5-62t77.5-51q49-22 114.5-34.5T480-840q74 0 139.5 12.5T734-793q49 22 77.5 51t28.5 62q0 33-28.5 62T734-567q-49 22-114.5 34.5T480-520q-85 0-157-15t-123-44v101q40 37 100 54t121 22q-8 15-13 34.5t-7 43.5q-60-7-111.5-20T200-379v99q14 25 77 47t142 30ZM864-40 756-148q-22 13-46 20.5t-50 7.5q-75 0-127.5-52.5T480-300q0-75 52.5-127.5T660-480q75 0 127.5 52.5T840-300q0 26-7.5 50T812-204L920-96l-56 56ZM660-200q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Z"/></svg>
|
||||
|
After Width: | Height: | Size: 992 B |
BIN
icons/logo.ico
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 264 KiB |
1
icons/manage_search.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M80-200v-80h400v80H80Zm0-200v-80h200v80H80Zm0-200v-80h200v80H80Zm744 400L670-354q-24 17-52.5 25.5T560-320q-83 0-141.5-58.5T360-520q0-83 58.5-141.5T560-720q83 0 141.5 58.5T760-520q0 29-8.5 57.5T726-410l154 154-56 56ZM560-400q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Z"/></svg>
|
||||
|
After Width: | Height: | Size: 420 B |
1
icons/print.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M640-640v-120H320v120h-80v-200h480v200h-80Zm-480 80h640-640Zm560 100q17 0 28.5-11.5T760-500q0-17-11.5-28.5T720-540q-17 0-28.5 11.5T680-500q0 17 11.5 28.5T720-460Zm-80 260v-160H320v160h320Zm80 80H240v-160H80v-240q0-51 35-85.5t85-34.5h560q51 0 85.5 34.5T880-520v240H720v160Zm80-240v-160q0-17-11.5-28.5T760-560H200q-17 0-28.5 11.5T160-520v160h80v-80h480v80h80Z"/></svg>
|
||||
|
After Width: | Height: | Size: 482 B |
1
icons/search_results.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M400-320q100 0 170-70t70-170q0-100-70-170t-170-70q-100 0-170 70t-70 170q0 100 70 170t170 70Zm-40-120v-280h80v280h-80Zm-140 0v-200h80v200h-80Zm280 0v-160h80v160h-80ZM824-80 597-307q-41 32-91 49.5T400-240q-134 0-227-93T80-560q0-134 93-227t227-93q134 0 227 93t93 227q0 56-17.5 106T653-363l227 227-56 56Z"/></svg>
|
||||
|
After Width: | Height: | Size: 425 B |
1
icons/trash.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
|
||||
|
After Width: | Height: | Size: 319 B |
33
mail.py
@@ -1,33 +0,0 @@
|
||||
|
||||
from PyQt6 import QtWidgets
|
||||
|
||||
from src.ui.dialogs.mail import Mail_Dialog
|
||||
|
||||
|
||||
def launch_gui(app_id="", app_name="", app_subject="", prof_name="", prof_mail=""):
|
||||
QtWidgets.QApplication([""])
|
||||
|
||||
dialog = Mail_Dialog(
|
||||
app_id=app_id,
|
||||
app_name=app_name,
|
||||
app_subject=app_subject,
|
||||
prof_name=prof_name,
|
||||
prof_mail=prof_mail,
|
||||
# default_mail="Information bezüglich der Auflösung des Semesterapparates",
|
||||
)
|
||||
dialog.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app_id = "123"
|
||||
app_name = "Test"
|
||||
app_subject = "TestFach"
|
||||
prof_name = "Test"
|
||||
prof_mail = "kirchneralexander020@gmail.com"
|
||||
launch_gui(
|
||||
app_id=app_id,
|
||||
app_name=app_name,
|
||||
app_subject=app_subject,
|
||||
prof_name=prof_name,
|
||||
prof_mail=prof_mail,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
Subject: Bitte um Bestellung von Neuerwerbungen für Semesterapparat {AppNr} - {AppName}
|
||||
|
||||
|
||||
Hallo zusammen,
|
||||
|
||||
für den Semesterapparat {AppNr} - {Appname} wurden folgende Neuauflagen gefunden:
|
||||
|
||||
{newEditionsOrdered}
|
||||
|
||||
Wäre es möglich, diese, oder neuere Auflagen (wenn vorhanden), zu bestellen?
|
||||
|
||||
{signature}
|
||||
@@ -1,37 +1,17 @@
|
||||
Message-ID: <b44248a9-025e-e86c-85d7-5949534f0ac4@ph-freiburg.de>
|
||||
Date: Mon, 17 Jul 2023 12:59:04 +0200
|
||||
MIME-Version: 1.0
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101
|
||||
Thunderbird/102.13.0
|
||||
Content-Language: de-DE
|
||||
From: {user_name} <{user_mail}>
|
||||
Subject: =?UTF-8?Q?Information_bez=c3=bcglich_der_Aufl=c3=b6sung_des_Semeste?=
|
||||
=?UTF-8?Q?rapparates_=7bAppNr=7d?=
|
||||
X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0;
|
||||
attachmentreminder=0; deliveryformat=0
|
||||
X-Identity-Key: id1
|
||||
Fcc: imap://aky547@imap.ph-freiburg.de/INBOX/Sent
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>{greeting}
|
||||
<br>
|
||||
<p>auf die E-Mail bezüglich der Auflösung oder Verlängerung der Semesterapparate haben wir von Ihnen keine Rückmeldung erhalten. Deshalb gehen wir davon aus, dass der Apparat aufgelöst werden kann.</p>
|
||||
<p> Die Medien, die in den Apparaten aufgestellt waren, werden nun wieder regulär ausleihbar und sind dann an ihren Standorten bei den Fächern zu finden.</p>
|
||||
<p></p>
|
||||
<p>Falls Sie den Apparat erneut, oder einen neuen Apparat anlegen wollen, können Sie mir das ausgefüllte Formular zur Einrichtung des Apparates (<a class="moz-txt-link-freetext" href="https://www.ph-freiburg.de/bibliothek/lernen/semesterapparate/info-lehrende-sem.html">https://www.ph-freiburg.de/bibliothek/lernen/semesterapparate/info-lehrende-sem.html</a>) zukommen lassen.</p>
|
||||
<p>Im Falle einer Verlängerung des Apparates reicht eine Antwort auf diese Mail.<br>
|
||||
</p>
|
||||
<p>Bei Fragen können Sie sich jederzeit an mich wenden.<br>
|
||||
</p>
|
||||
<p><br>
|
||||
</p>
|
||||
<pre class="moz-signature" cols="72">--
|
||||
Subject: Information bezüglich der Auflösung des Semesterapparates {AppNr}
|
||||
|
||||
|
||||
{greeting}
|
||||
|
||||
auf die E-Mail bezüglich der Auflösung oder Verlängerung der Semesterapparate haben wir von Ihnen keine Rückmeldung erhalten. Deshalb gehen wir davon aus, dass der Apparat aufgelöst werden kann.
|
||||
Die Medien, die in den Apparaten aufgestellt waren, werden nun wieder regulär ausleihbar und sind dann an ihren Standorten bei den Fächern zu finden.
|
||||
|
||||
Falls Sie den Apparat erneut, oder einen neuen Apparat anlegen wollen,
|
||||
können Sie mir das ausgefüllte Formular zur Einrichtung des Apparates
|
||||
https://www.ph-freiburg.de/bibliothek/lernen/semesterapparate/info-lehrende-sem.html
|
||||
zukommen lassen. Im Falle einer Verlängerung des Apparates reicht eine Antwort auf diese Mail.
|
||||
|
||||
Bei Fragen können Sie sich jederzeit an mich wenden.
|
||||
|
||||
{signature}
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,36 +1,16 @@
|
||||
Message-ID: <db617c48-29d6-d3d8-a67c-e9a6cf9b5bdb@ph-freiburg.de>
|
||||
Date: Tue, 12 Sep 2023 13:01:35 +0200
|
||||
MIME-Version: 1.0
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101
|
||||
Thunderbird/102.15.0
|
||||
From: Alexander Kirchner <alexander.kirchner@ph-freiburg.de>
|
||||
Subject: Information zum Semesterapparat {AppNr} - {Appname}
|
||||
Content-Language: de-DE
|
||||
X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0;
|
||||
attachmentreminder=0; deliveryformat=0
|
||||
X-Identity-Key: id1
|
||||
Fcc: imap://aky547@imap.ph-freiburg.de/INBOX/Sent
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>{greeting}
|
||||
<br>
|
||||
<p>Ihr Semesterapparat {Appname} wurde angelegt.</p>
|
||||
<p>Unter folgendem Link können Sie die Apparate einsehen:</p>
|
||||
<p><a class="moz-txt-link-freetext" href="https://bsz.ibs-bw.de/aDISWeb/app?service=direct/0/Home/$DirectLink&sp=SOPAC42&sp=SWI00000002&noRedir">https://bsz.ibs-bw.de/aDISWeb/app?service=direct/0/Home/$DirectLink&sp=SOPAC42&sp=SWI00000002&noRedir</a></p>
|
||||
<p>Ihr Apparat ist unter {AppSubject} > {Profname} > {AppNr} {Appname}.<br>
|
||||
</p>
|
||||
<p><br>
|
||||
</p>
|
||||
<p>Noch nicht vorhandene Medien wurden vorgemerkt und werden nach Rückkehr in die Bibliothek eingearbeitet.</p>
|
||||
<p>Bei Fragen können Sie sich per Mail bei mir melden.<br>
|
||||
</p>
|
||||
<pre class="moz-signature" cols="72">--
|
||||
Subject: Information zum Semesterapparat {AppNr} - {AppName}
|
||||
|
||||
|
||||
{greeting}
|
||||
|
||||
Ihr Semesterapparat {Appname} wurde angelegt.
|
||||
Unter folgendem Link können Sie die Apparate einsehen:
|
||||
https://bsz.ibs-bw.de/aDISWeb/app?service=direct/0/Home/$DirectLink&sp=SOPAC42&sp=SWI00000002&noRedir
|
||||
|
||||
Ihr Apparat ist unter {AppSubject} > {Profname} > {AppNr} {Appname}
|
||||
|
||||
Noch nicht vorhandene Medien wurden vorgemerkt und werden nach Rückkehr in die Bibliothek eingearbeitet.
|
||||
Bei Fragen können Sie sich per Mail bei mir melden.
|
||||
|
||||
{signature}
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
Subject: Information zur Auflösung des Semesterapparates {AppNr} - {Appname}
|
||||
|
||||
|
||||
{greeting}
|
||||
|
||||
Ihr Semesterapparat "{Appname} ({AppNr})" wurde wie besprochen aufgelöst.
|
||||
Die Medien sind von nun an wieder in den Regalen zu finden.
|
||||
|
||||
{signature}
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
Subject: Neuauflagen für Semesterapparat {AppNr} - {AppName}
|
||||
|
||||
|
||||
{greeting}
|
||||
|
||||
Für Ihren Semesterapparat {AppNr} - {Appname} wurden folgende Neuauflagen gefunden:
|
||||
|
||||
{newEditions}
|
||||
|
||||
Sollen wir die alte(n) Auflage(n) aus dem Apparat durch diese austauschen?
|
||||
Nicht vorhandene Exemplare werden an die Erwerbungsabteilung weitergegeben
|
||||
und nach Erhalt der Medien in den Apparat eingearbeitet.
|
||||
|
||||
{signature}
|
||||
9
mail_vorlagen/blankomail.eml
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
Subject: CHANGEME
|
||||
|
||||
|
||||
{greeting}
|
||||
|
||||
|
||||
|
||||
{signature}
|
||||
18
main.py
@@ -1,4 +1,22 @@
|
||||
import sys
|
||||
|
||||
from PySide6 import QtWidgets
|
||||
|
||||
from src import first_launch, settings
|
||||
from src.shared.logging import configure
|
||||
from src.ui.userInterface import launch_gui as UI
|
||||
from src.ui.widgets.welcome_wizard import launch_wizard as startup
|
||||
|
||||
if __name__ == "__main__":
|
||||
configure("INFO")
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
if not first_launch:
|
||||
setup = startup()
|
||||
if setup == 1:
|
||||
settings.reload()
|
||||
# kill qApplication singleton
|
||||
UI()
|
||||
else:
|
||||
sys.exit()
|
||||
else:
|
||||
UI()
|
||||
|
||||
@@ -1,35 +1,62 @@
|
||||
[project]
|
||||
name = "semesterapparatsmanager"
|
||||
version = "0.1.0"
|
||||
version = "1.0.2"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.12.3",
|
||||
"appdirs>=1.4.4",
|
||||
"beautifulsoup4>=4.13.5",
|
||||
"bump-my-version>=0.29.0",
|
||||
"chardet>=5.2.0",
|
||||
"charset-normalizer>=3.4.3",
|
||||
"comtypes>=1.4.9",
|
||||
"darkdetect>=0.8.0",
|
||||
"docx2pdf>=0.1.8",
|
||||
"httpx>=0.28.1",
|
||||
"loguru>=0.7.3",
|
||||
"mkdocs>=1.6.1",
|
||||
"mkdocs-material>=9.5.49",
|
||||
"mkdocs-material-extensions>=1.3.1",
|
||||
"natsort>=8.4.0",
|
||||
"omegaconf>=2.3.0",
|
||||
"openai>=1.79.0",
|
||||
"pandas>=2.2.3",
|
||||
"pdfquery>=0.4.3",
|
||||
"playwright>=1.49.1",
|
||||
"pyqt6>=6.8.0",
|
||||
"pyqtgraph>=0.13.7",
|
||||
"pymupdf>=1.26.6",
|
||||
"pyramid>=2.0.2",
|
||||
"pyside6>=6.9.1",
|
||||
"python-docx>=1.1.2",
|
||||
"pyzotero>=1.6.4",
|
||||
"ratelimit>=2.2.1",
|
||||
"regex>=2025.11.3",
|
||||
"requests>=2.32.3",
|
||||
"zensical>=0.0.10",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"bump-my-version>=0.29.0",
|
||||
"icecream>=2.1.4",
|
||||
"nuitka>=2.5.9",
|
||||
]
|
||||
dev = ["bump-my-version>=0.29.0", "icecream>=2.1.4", "nuitka>=2.5.9"]
|
||||
swbtest = ["alive-progress>=3.3.0"]
|
||||
|
||||
[tool.bumpversion]
|
||||
current_version = "1.0.2"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
serialize = ["{major}.{minor}.{patch}"]
|
||||
search = "{current_version}"
|
||||
replace = "{new_version}"
|
||||
regex = false
|
||||
ignore_missing_version = false
|
||||
ignore_missing_files = false
|
||||
tag = true
|
||||
sign_tags = false
|
||||
tag_name = "v{new_version}"
|
||||
tag_message = "Bump version: {current_version} → {new_version}"
|
||||
allow_dirty = true
|
||||
commit = true
|
||||
message = "Bump version: {current_version} → {new_version}"
|
||||
moveable_tags = []
|
||||
commit_args = ""
|
||||
setup_hooks = []
|
||||
pre_commit_hooks = []
|
||||
post_commit_hooks = []
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "src/__init__.py"
|
||||
[[tool.bumpversion.files]]
|
||||
filename = ".version"
|
||||
|
||||
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -1,27 +1,35 @@
|
||||
import sys
|
||||
from config import Config
|
||||
import os
|
||||
from loguru import logger as log
|
||||
from datetime import datetime
|
||||
|
||||
settings = Config("config/config.yaml")
|
||||
from .utils.icon import Icon
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "1.0.2"
|
||||
__author__ = "Alexander Kirchner"
|
||||
__all__ = ["__version__", "__author__", "Icon", "settings"]
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from appdirs import AppDirs
|
||||
|
||||
from config import Config
|
||||
|
||||
app = AppDirs("SemesterApparatsManager", "SAM")
|
||||
LOG_DIR: str = app.user_log_dir # type: ignore
|
||||
CONFIG_DIR: str = app.user_config_dir # type: ignore
|
||||
if not os.path.exists(LOG_DIR): # type: ignore
|
||||
os.makedirs(LOG_DIR) # type: ignore
|
||||
if not os.path.exists(CONFIG_DIR): # type: ignore
|
||||
os.makedirs(CONFIG_DIR) # type: ignore
|
||||
|
||||
|
||||
settings = Config(f"{CONFIG_DIR}/config.yaml")
|
||||
DATABASE_DIR: Union[Path, str] = ( # type: ignore
|
||||
app.user_config_dir if settings.database.path is None else settings.database.path # type: ignore
|
||||
)
|
||||
if not os.path.exists(DATABASE_DIR): # type: ignore
|
||||
os.makedirs(DATABASE_DIR) # type: ignore
|
||||
first_launch = settings.exists
|
||||
if not os.path.exists(settings.database.temp.expanduser()): # type: ignore
|
||||
settings.database.temp.expanduser().mkdir(parents=True, exist_ok=True) # type: ignore
|
||||
from .utils.icon import Icon
|
||||
|
||||
if not os.path.exists("logs"):
|
||||
os.mkdir("logs")
|
||||
# open and close the file to create it
|
||||
logger = log
|
||||
logger.remove()
|
||||
logger.add("logs/application.log", rotation="1 week", enqueue=True)
|
||||
log.add(
|
||||
f"logs/{datetime.now().strftime('%Y-%m-%d')}.log",
|
||||
rotation="1 day",
|
||||
compression="zip",
|
||||
)
|
||||
|
||||
# logger.add(sys.stderr, format="{time} {level} {message}", level="INFO")
|
||||
logger.add(sys.stdout)
|
||||
|
||||
5
src/admin/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Administrative functions and commands."""
|
||||
|
||||
from .commands import AdminCommands
|
||||
|
||||
__all__ = ["AdminCommands"]
|
||||
103
src/admin/commands.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
from src.database import Database
|
||||
from src.shared.logging import log
|
||||
|
||||
|
||||
# change passwords for apparats, change passwords for users, list users, create and delete users etc
|
||||
# create a class that has all commands. for each command, create a function that does the thing
|
||||
class AdminCommands:
|
||||
"""Basic Admin commands for the admin console. This class is used to create, delete, and list users. It also has the ability to change passwords for users."""
|
||||
|
||||
def __init__(self, db_path=None):
|
||||
"""Default Constructor for the AdminCommands class."""
|
||||
if db_path is None:
|
||||
self.db = Database()
|
||||
else:
|
||||
self.db = Database(db_path=db_path)
|
||||
log.info("AdminCommands initialized with database connection.")
|
||||
log.debug("location: {}", self.db.db_path)
|
||||
|
||||
def create_password(self, password: str) -> tuple[str, str]:
|
||||
"""Create a hashed password and a salt for the password.
|
||||
|
||||
Args:
|
||||
password (str): the base password to be hashed.
|
||||
|
||||
Returns:
|
||||
tuple[str,str]: a tuple containing the hashed password and the salt used to hash the password.
|
||||
"""
|
||||
salt = self.create_salt()
|
||||
hashed_password = self.hash_password(password)
|
||||
return (hashed_password, salt)
|
||||
|
||||
def create_salt(self) -> str:
|
||||
"""Generate a random 16 digit long salt for the password.
|
||||
|
||||
Returns:
|
||||
str: the randomized salt
|
||||
"""
|
||||
return "".join(
|
||||
random.choices(
|
||||
"abcdefghijklmnopqrstuvwxyzQWERTZUIOPLKJHGFDSAYXCVBNM0123456789", k=16
|
||||
)
|
||||
)
|
||||
|
||||
def create_admin(self):
|
||||
"""Create the admin in the database. This is only used once, when the database is created."""
|
||||
salt = self.create_salt()
|
||||
hashed_password = self.hash_password("admin")
|
||||
self.db.createUser("admin", salt + hashed_password, "admin", salt)
|
||||
|
||||
def create_user(self, username: str, password: str, role: str = "user") -> bool:
|
||||
"""Create a new user in the database.
|
||||
|
||||
Args:
|
||||
username (str): the username of the user to be created.
|
||||
password (str): the password of the user to be created.
|
||||
role (str, optional): the role of the user to be created. Defaults to "user".
|
||||
"""
|
||||
hashed_password, salt = self.create_password(password)
|
||||
status = self.db.createUser(
|
||||
user=username, password=salt + hashed_password, role=role, salt=salt
|
||||
)
|
||||
return status
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""Hash a password using SHA256.
|
||||
|
||||
Args:
|
||||
password (str): the password to be hashed.
|
||||
|
||||
Returns:
|
||||
str: the hashed password.
|
||||
"""
|
||||
hashed = hashlib.sha256((password).encode("utf-8")).hexdigest()
|
||||
return hashed
|
||||
|
||||
def list_users(self) -> list[tuple]:
|
||||
"""List all available users in the database.
|
||||
|
||||
Returns:
|
||||
list[tuple]: a list of all users, containing all stored data for each user in a tuple.
|
||||
"""
|
||||
return self.db.getUsers()
|
||||
|
||||
def delete_user(self, username: str):
|
||||
"""Delete a selected user from the database.
|
||||
|
||||
Args:
|
||||
username (str): the username of the user to be deleted.
|
||||
"""
|
||||
self.db.deleteUser(username)
|
||||
|
||||
def change_password(self, username, password):
|
||||
"""change the password for a user.
|
||||
|
||||
Args:
|
||||
username (str): username of the user to change the password for.
|
||||
password (str): the new, non-hashed password to change to.
|
||||
"""
|
||||
hashed_password = self.hash_password(password)
|
||||
self.db.changePassword(username, hashed_password)
|
||||
@@ -1,7 +1,22 @@
|
||||
from .database import Database
|
||||
from .semester import Semester
|
||||
__all__ = [
|
||||
"AdminCommands",
|
||||
"AutoAdder",
|
||||
"AvailChecker",
|
||||
"BookGrabber",
|
||||
"Database",
|
||||
"DocumentationThread",
|
||||
"NewEditionCheckerThread",
|
||||
"recreateElsaFile",
|
||||
"recreateFile",
|
||||
"Catalogue",
|
||||
]
|
||||
|
||||
from .admin_console import AdminCommands
|
||||
from .thread_bookgrabber import BookGrabber
|
||||
from .threads_availchecker import AvailChecker
|
||||
from .threads_autoadder import AutoAdder
|
||||
from .catalogue import Catalogue
|
||||
from .create_file import recreateElsaFile, recreateFile
|
||||
from .database import Database
|
||||
from .documentation_thread import DocumentationThread
|
||||
from .thread_bookgrabber import BookGrabber
|
||||
from .thread_neweditions import NewEditionCheckerThread
|
||||
from .threads_autoadder import AutoAdder
|
||||
from .threads_availchecker import AvailChecker
|
||||
|
||||
@@ -2,6 +2,14 @@ import hashlib
|
||||
import random
|
||||
|
||||
from .database import Database
|
||||
import loguru
|
||||
import sys
|
||||
from src import LOG_DIR
|
||||
|
||||
log = loguru.logger
|
||||
log.remove()
|
||||
log.add(sys.stdout, level="INFO")
|
||||
log.add(f"{LOG_DIR}/application.log", rotation="1 MB", retention="10 days")
|
||||
|
||||
|
||||
# change passwords for apparats, change passwords for users, list users, create and delete users etc
|
||||
@@ -9,9 +17,14 @@ from .database import Database
|
||||
class AdminCommands:
|
||||
"""Basic Admin commands for the admin console. This class is used to create, delete, and list users. It also has the ability to change passwords for users."""
|
||||
|
||||
def __init__(self):
|
||||
"""Defaulf Constructor for the AdminCommands class."""
|
||||
def __init__(self, db_path=None):
|
||||
"""Default Constructor for the AdminCommands class."""
|
||||
if db_path is None:
|
||||
self.db = Database()
|
||||
else:
|
||||
self.db = Database(db_path=db_path)
|
||||
log.info("AdminCommands initialized with database connection.")
|
||||
log.debug("location: {}", self.db.db_path)
|
||||
|
||||
def create_password(self, password: str) -> tuple[str, str]:
|
||||
"""Create a hashed password and a salt for the password.
|
||||
@@ -44,6 +57,20 @@ class AdminCommands:
|
||||
hashed_password = self.hash_password("admin")
|
||||
self.db.createUser("admin", salt + hashed_password, "admin", salt)
|
||||
|
||||
def create_user(self, username: str, password: str, role: str = "user") -> bool:
|
||||
"""Create a new user in the database.
|
||||
|
||||
Args:
|
||||
username (str): the username of the user to be created.
|
||||
password (str): the password of the user to be created.
|
||||
role (str, optional): the role of the user to be created. Defaults to "user".
|
||||
"""
|
||||
hashed_password, salt = self.create_password(password)
|
||||
status = self.db.createUser(
|
||||
user=username, password=salt + hashed_password, role=role, salt=salt
|
||||
)
|
||||
return status
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""Hash a password using SHA256.
|
||||
|
||||
|
||||
292
src/backend/catalogue.py
Normal file
@@ -0,0 +1,292 @@
|
||||
from typing import List
|
||||
|
||||
import regex
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.logic import BookData as Book
|
||||
from src.shared.logging import log
|
||||
|
||||
URL = "https://rds.ibs-bw.de/phfreiburg/opac/RDSIndex/Search?type0%5B%5D=allfields&lookfor0%5B%5D={}&join=AND&bool0%5B%5D=AND&type0%5B%5D=au&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=ti&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=ct&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=isn&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=ta&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=co&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=py&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=pp&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=pu&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=si&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=zr&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND&type0%5B%5D=cc&lookfor0%5B%5D=&join=AND&bool0%5B%5D=AND"
|
||||
BASE = "https://rds.ibs-bw.de"
|
||||
|
||||
|
||||
class Catalogue:
|
||||
def __init__(self, timeout=15):
|
||||
self.timeout = timeout
|
||||
reachable = self.check_connection()
|
||||
if not reachable:
|
||||
log.error("No internet connection available.")
|
||||
raise ConnectionError("No internet connection available.")
|
||||
|
||||
def check_connection(self):
|
||||
try:
|
||||
response = requests.get("https://www.google.com", timeout=self.timeout)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.error(f"Could not connect to google.com: {e}")
|
||||
|
||||
def search_book(self, searchterm: str):
|
||||
response = requests.get(URL.format(searchterm), timeout=self.timeout)
|
||||
return response.text
|
||||
|
||||
def search(self, link: str):
|
||||
response = requests.get(link, timeout=self.timeout)
|
||||
return response.text
|
||||
|
||||
def get_book_links(self, searchterm: str) -> List[str]:
|
||||
response = self.search_book(searchterm)
|
||||
soup = BeautifulSoup(response, "html.parser")
|
||||
links = soup.find_all("a", class_="title getFull")
|
||||
res: List[str] = []
|
||||
for link in links:
|
||||
res.append(BASE + link["href"]) # type: ignore
|
||||
return res
|
||||
|
||||
def get_book(self, searchterm: str):
|
||||
log.info(f"Searching for term: {searchterm}")
|
||||
|
||||
links = self.get_book_links(searchterm)
|
||||
print(links)
|
||||
for elink in links:
|
||||
result = self.search(elink)
|
||||
# in result search for class col-xs-12 rds-dl RDS_LOCATION
|
||||
# if found, return text of href
|
||||
soup = BeautifulSoup(result, "html.parser")
|
||||
|
||||
# Optional (unchanged): title and ppn if you need them
|
||||
title_el = soup.find("div", class_="headline text")
|
||||
title = title_el.get_text(strip=True) if title_el else None
|
||||
|
||||
ppn_el = soup.find(
|
||||
"div", class_="col-xs-12 col-md-5 col-lg-4 rds-dl-head RDS_PPN"
|
||||
)
|
||||
# in ppn_el, get text of div col-xs-12 col-md-7 col-lg-8 rds-dl-panel
|
||||
ppn = (
|
||||
ppn_el.find_next_sibling(
|
||||
"div", class_="col-xs-12 col-md-7 col-lg-8 rds-dl-panel"
|
||||
).get_text(strip=True)
|
||||
if ppn_el
|
||||
else None
|
||||
)
|
||||
|
||||
# get edition text at div class col-xs-12 col-md-5 col-lg-4 rds-dl-head RDS_EDITION
|
||||
edition_el = soup.find(
|
||||
"div", class_="col-xs-12 col-md-5 col-lg-4 rds-dl-head RDS_EDITION"
|
||||
)
|
||||
edition = (
|
||||
edition_el.find_next_sibling(
|
||||
"div", class_="col-xs-12 col-md-7 col-lg-8 rds-dl-panel"
|
||||
).get_text(strip=True)
|
||||
if edition_el
|
||||
else None
|
||||
)
|
||||
|
||||
authors = soup.find_all(
|
||||
"div", class_="col-xs-12 col-md-5 col-lg-4 rds-dl-head RDS_PERSON"
|
||||
)
|
||||
author = None
|
||||
if authors:
|
||||
# get the names of the a href links in the div col-xs-12 col-md-7 col-lg-8 rds-dl-panel
|
||||
author_names = []
|
||||
for author in authors:
|
||||
panel = author.find_next_sibling(
|
||||
"div", class_="col-xs-12 col-md-7 col-lg-8 rds-dl-panel"
|
||||
)
|
||||
if panel:
|
||||
links = panel.find_all("a")
|
||||
for link in links:
|
||||
author_names.append(link.text.strip())
|
||||
author = (
|
||||
";".join(author_names) if len(author_names) > 1 else author_names[0]
|
||||
)
|
||||
signature = None
|
||||
|
||||
panel = soup.select_one("div.panel-body")
|
||||
if panel:
|
||||
# Collect the RDS_* blocks in order, using the 'space' divs as separators
|
||||
groups = []
|
||||
cur = {}
|
||||
for node in panel.select(
|
||||
"div.rds-dl.RDS_SIGNATURE, div.rds-dl.RDS_STATUS, div.rds-dl.RDS_LOCATION, div.col-xs-12.space"
|
||||
):
|
||||
classes = node.get("class", [])
|
||||
# Separator between entries
|
||||
if "space" in classes:
|
||||
if cur:
|
||||
groups.append(cur)
|
||||
cur = {}
|
||||
continue
|
||||
|
||||
# Read the value from the corresponding panel cell
|
||||
val_el = node.select_one(".rds-dl-panel")
|
||||
val = (
|
||||
val_el.get_text(" ", strip=True)
|
||||
if val_el
|
||||
else node.get_text(" ", strip=True)
|
||||
)
|
||||
|
||||
if "RDS_SIGNATURE" in classes:
|
||||
cur["signature"] = val
|
||||
elif "RDS_STATUS" in classes:
|
||||
cur["status"] = val
|
||||
elif "RDS_LOCATION" in classes:
|
||||
cur["location"] = val
|
||||
|
||||
if cur: # append the last group if not followed by a space
|
||||
groups.append(cur)
|
||||
|
||||
# Find the signature for the entry whose location mentions "Semesterapparat"
|
||||
for g in groups:
|
||||
loc = g.get("location", "").lower()
|
||||
if "semesterapparat" in loc:
|
||||
signature = g.get("signature")
|
||||
return Book(
|
||||
title=title,
|
||||
ppn=ppn,
|
||||
signature=signature,
|
||||
library_location=loc.split("-")[-1],
|
||||
link=elink,
|
||||
author=author,
|
||||
edition=edition,
|
||||
)
|
||||
else:
|
||||
return Book(
|
||||
title=title,
|
||||
ppn=ppn,
|
||||
signature=signature,
|
||||
library_location=loc.split("\n\n")[-1],
|
||||
link=elink,
|
||||
author=author,
|
||||
edition=edition,
|
||||
)
|
||||
|
||||
def get(self, ppn: str) -> Book | None:
|
||||
# based on PPN, get title, people, edition, year, language, pages, isbn,
|
||||
link = f"https://rds.ibs-bw.de/phfreiburg/opac/RDSIndexrecord/{ppn}"
|
||||
result = self.search(link)
|
||||
soup = BeautifulSoup(result, "html.parser")
|
||||
|
||||
def get_ppn(self, searchterm: str) -> str | None:
|
||||
links = self.get_book_links(searchterm)
|
||||
ppn = None
|
||||
for link in links:
|
||||
result = self.search(link)
|
||||
soup = BeautifulSoup(result, "html.parser")
|
||||
print(link)
|
||||
ppn = link.split("/")[-1]
|
||||
if ppn and regex.match(r"^\d{8,10}[X\d]?$", ppn):
|
||||
return ppn
|
||||
return ppn
|
||||
|
||||
def get_semesterapparat_number(self, searchterm: str) -> int:
|
||||
links = self.get_book_links(searchterm)
|
||||
for link in links:
|
||||
result = self.search(link)
|
||||
# in result search for class col-xs-12 rds-dl RDS_LOCATION
|
||||
# if found, return text of href
|
||||
soup = BeautifulSoup(result, "html.parser")
|
||||
|
||||
locations = soup.find_all("div", class_="col-xs-12 rds-dl RDS_LOCATION")
|
||||
for location_el in locations:
|
||||
if "Semesterapparat-" in location_el.text:
|
||||
match = regex.search(r"Semesterapparat-(\d+)", location_el.text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
if "Handbibliothek-" in location_el.text:
|
||||
return location_el.text.strip().split("\n\n")[-1].strip()
|
||||
return location_el.text.strip().split("\n\n")[-1].strip()
|
||||
return 0
|
||||
|
||||
def get_author(self, link: str) -> str:
|
||||
links = self.get_book_links(f"kid:{link}")
|
||||
author = None
|
||||
for link in links:
|
||||
# print(link)
|
||||
result = self.search(link)
|
||||
soup = BeautifulSoup(result, "html.parser")
|
||||
# get all authors, return them as a string seperated by ;
|
||||
authors = soup.find_all(
|
||||
"div", class_="col-xs-12 col-md-5 col-lg-4 rds-dl-head RDS_PERSON"
|
||||
)
|
||||
if authors:
|
||||
# get the names of the a href links in the div col-xs-12 col-md-7 col-lg-8 rds-dl-panel
|
||||
author_names = []
|
||||
for author in authors:
|
||||
panel = author.find_next_sibling(
|
||||
"div", class_="col-xs-12 col-md-7 col-lg-8 rds-dl-panel"
|
||||
)
|
||||
if panel:
|
||||
links = panel.find_all("a")
|
||||
for link in links:
|
||||
author_names.append(link.text.strip())
|
||||
author = "; ".join(author_names)
|
||||
return author
|
||||
|
||||
def get_signature(self, isbn: str):
|
||||
links = self.get_book_links(f"{isbn}")
|
||||
signature = None
|
||||
for link in links:
|
||||
result = self.search(link)
|
||||
soup = BeautifulSoup(result, "html.parser")
|
||||
panel = soup.select_one("div.panel-body")
|
||||
if panel:
|
||||
# Collect the RDS_* blocks in order, using the 'space' divs as separators
|
||||
groups = []
|
||||
cur = {}
|
||||
for node in panel.select(
|
||||
"div.rds-dl.RDS_SIGNATURE, div.rds-dl.RDS_STATUS, div.rds-dl.RDS_LOCATION, div.col-xs-12.space"
|
||||
):
|
||||
classes = node.get("class", [])
|
||||
# Separator between entries
|
||||
if "space" in classes:
|
||||
if cur:
|
||||
groups.append(cur)
|
||||
cur = {}
|
||||
continue
|
||||
|
||||
# Read the value from the corresponding panel cell
|
||||
val_el = node.select_one(".rds-dl-panel")
|
||||
val = (
|
||||
val_el.get_text(" ", strip=True)
|
||||
if val_el
|
||||
else node.get_text(" ", strip=True)
|
||||
)
|
||||
|
||||
if "RDS_SIGNATURE" in classes:
|
||||
cur["signature"] = val
|
||||
elif "RDS_STATUS" in classes:
|
||||
cur["status"] = val
|
||||
elif "RDS_LOCATION" in classes:
|
||||
cur["location"] = val
|
||||
|
||||
if cur: # append the last group if not followed by a space
|
||||
groups.append(cur)
|
||||
|
||||
# Find the signature for the entry whose location mentions "Semesterapparat"
|
||||
for g in groups:
|
||||
print(g)
|
||||
loc = g.get("location", "").lower()
|
||||
if "semesterapparat" in loc:
|
||||
signature = g.get("signature")
|
||||
return signature
|
||||
else:
|
||||
signature = g.get("signature")
|
||||
return signature
|
||||
print("No signature found")
|
||||
return signature
|
||||
|
||||
def in_library(self, ppn: str) -> bool:
|
||||
if ppn is None:
|
||||
return False
|
||||
links = self.get_book_links(f"kid:{ppn}")
|
||||
return len(links) > 0
|
||||
|
||||
def get_location(self, ppn: str) -> str | None:
|
||||
if ppn is None:
|
||||
return None
|
||||
link = self.get_book(f"{ppn}")
|
||||
if link is None:
|
||||
return None
|
||||
return link.library_location
|
||||
@@ -4,10 +4,18 @@ from pathlib import Path
|
||||
|
||||
from src.backend.database import Database
|
||||
|
||||
import loguru
|
||||
import sys
|
||||
from src import LOG_DIR
|
||||
log = loguru.logger
|
||||
log.remove()
|
||||
log.add(sys.stdout, level="INFO")
|
||||
log.add(f"{LOG_DIR}/application.log", rotation="1 MB", retention="10 days")
|
||||
|
||||
|
||||
db = Database()
|
||||
|
||||
|
||||
def recreateFile(name, app_id, filetype, open=True) -> Path:
|
||||
def recreateFile(name: str, app_id: int, filetype: str, open: bool = True) -> Path:
|
||||
"""
|
||||
recreateFile creates a file from the database and opens it in the respective program, if the open parameter is set to True.
|
||||
|
||||
@@ -24,6 +32,7 @@ def recreateFile(name, app_id, filetype, open=True) -> Path:
|
||||
"""
|
||||
path = db.recreateFile(name, app_id, filetype=filetype)
|
||||
path = Path(path)
|
||||
log.info(f"File created: {path}")
|
||||
if open:
|
||||
if os.getenv("OS") == "Windows_NT":
|
||||
path = path.resolve()
|
||||
|
||||
@@ -12,12 +12,12 @@ CREATE_TABLE_APPARAT = """CREATE TABLE semesterapparat (
|
||||
deleted_date TEXT,
|
||||
apparat_id_adis INTEGER,
|
||||
prof_id_adis INTEGER,
|
||||
konto INTEGER REFERENCES app_kontos (id),
|
||||
konto INTEGER,
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id)
|
||||
)"""
|
||||
CREATE_TABLE_MEDIA = """CREATE TABLE media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
bookdata BLOB,
|
||||
bookdata TEXT,
|
||||
app_id INTEGER,
|
||||
prof_id INTEGER,
|
||||
deleted INTEGER DEFAULT (0),
|
||||
@@ -26,13 +26,7 @@ CREATE_TABLE_MEDIA = """CREATE TABLE media (
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id),
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
)"""
|
||||
CREATE_TABLE_APPKONTOS = """CREATE TABLE app_kontos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
app_id INTEGER,
|
||||
konto INTEGER,
|
||||
passwort TEXT,
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
)"""
|
||||
|
||||
CREATE_TABLE_FILES = """CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filename TEXT,
|
||||
@@ -107,3 +101,12 @@ CREATE_ELSA_MEDIA_TABLE = """CREATE TABLE elsa_media (
|
||||
elsa_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (elsa_id) REFERENCES elsa (id)
|
||||
)"""
|
||||
CREATE_TABLE_NEWEDITIONS = """CREATE TABLE neweditions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
new_bookdata TEXT,
|
||||
old_edition_id INTEGER,
|
||||
for_apparat INTEGER,
|
||||
ordered BOOLEAN DEFAULT (0),
|
||||
FOREIGN KEY (old_edition_id) REFERENCES media (id),
|
||||
FOREIGN KEY (for_apparat) REFERENCES semesterapparat (id)
|
||||
)"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from src import settings
|
||||
|
||||
database = settings.database
|
||||
@@ -9,10 +8,7 @@ def delete_temp_contents():
|
||||
"""
|
||||
delete_temp_contents deletes the contents of the temp directory.
|
||||
"""
|
||||
path = database.temp
|
||||
path = path.replace("~", str(Path.home()))
|
||||
path = Path(path)
|
||||
path = path.resolve()
|
||||
path = database.temp.expanduser()
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
os.remove(os.path.join(root, file))
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
from PyQt6.QtCore import QThread
|
||||
from src.utils.documentation import run_mkdocs
|
||||
from PySide6.QtCore import QThread, Slot
|
||||
from src.utils.documentation import website, QuietHandler
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
|
||||
class DocumentationThread(QThread):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._server = None # store server so we can shut it down
|
||||
|
||||
def run(self):
|
||||
# launch_documentation()
|
||||
run_mkdocs()
|
||||
self._server = make_server(
|
||||
"localhost", 8000, website(), handler_class=QuietHandler
|
||||
)
|
||||
while not self.isInterruptionRequested():
|
||||
self._server.handle_request()
|
||||
|
||||
@Slot() # slot you can connect to aboutToQuit
|
||||
def stop(self):
|
||||
self.requestInterruption() # ask the loop above to exit
|
||||
if self._server:
|
||||
self._server.shutdown() # unblock handle_request()
|
||||
68
src/backend/migration_runner.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import os
|
||||
import sqlite3 as sql
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from src import DATABASE_DIR, settings
|
||||
from src.shared.logging import log
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
def _ensure_migrations_table(conn: sql.Connection) -> None:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _applied_migrations(conn: sql.Connection) -> List[str]:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id FROM schema_migrations ORDER BY id")
|
||||
rows = cursor.fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _apply_sql_file(conn: sql.Connection, path: Path) -> None:
|
||||
log.info(f"Applying migration {path.name}")
|
||||
sql_text = path.read_text(encoding="utf-8")
|
||||
cursor = conn.cursor()
|
||||
cursor.executescript(sql_text)
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO schema_migrations (id) VALUES (?)", (path.name,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def run_migrations(db_path: Path) -> None:
|
||||
"""Run all unapplied migrations from the migrations directory against the database at db_path."""
|
||||
if not MIGRATIONS_DIR.exists():
|
||||
log.debug("Migrations directory does not exist, skipping migrations")
|
||||
return
|
||||
|
||||
# Ensure database directory exists
|
||||
db_dir = settings.database.path or Path(DATABASE_DIR)
|
||||
if not db_dir.exists():
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
|
||||
conn = sql.connect(db_path)
|
||||
try:
|
||||
_ensure_migrations_table(conn)
|
||||
applied = set(_applied_migrations(conn))
|
||||
|
||||
migration_files = sorted(
|
||||
[p for p in MIGRATIONS_DIR.iterdir() if p.suffix in (".sql",)]
|
||||
)
|
||||
for m in migration_files:
|
||||
if m.name in applied:
|
||||
log.debug(f"Skipping already applied migration {m.name}")
|
||||
continue
|
||||
_apply_sql_file(conn, m)
|
||||
finally:
|
||||
conn.close()
|
||||
132
src/backend/migrations/V001__create_base_tables.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semesterapparat (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT,
|
||||
prof_id INTEGER,
|
||||
fach TEXT,
|
||||
appnr INTEGER,
|
||||
erstellsemester TEXT,
|
||||
verlängert_am TEXT,
|
||||
dauer BOOLEAN,
|
||||
verlängerung_bis TEXT,
|
||||
deletion_status INTEGER,
|
||||
deleted_date TEXT,
|
||||
apparat_id_adis INTEGER,
|
||||
prof_id_adis INTEGER,
|
||||
konto INTEGER,
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
bookdata TEXT,
|
||||
app_id INTEGER,
|
||||
prof_id INTEGER,
|
||||
deleted INTEGER DEFAULT (0),
|
||||
available BOOLEAN,
|
||||
reservation BOOLEAN,
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id),
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filename TEXT,
|
||||
fileblob BLOB,
|
||||
app_id INTEGER,
|
||||
filetyp TEXT,
|
||||
prof_id INTEGER REFERENCES prof (id),
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
created_at date NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
message TEXT NOT NULL,
|
||||
remind_at date NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id INTEGER NOT NULL,
|
||||
appnr INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES user (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prof (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
titel TEXT,
|
||||
fname TEXT,
|
||||
lname TEXT,
|
||||
fullname TEXT NOT NULL UNIQUE,
|
||||
mail TEXT,
|
||||
telnr TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subjects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elsa (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
semester TEXT NOT NULL,
|
||||
prof_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elsa_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
fileblob BLOB NOT NULL,
|
||||
elsa_id INTEGER NOT NULL,
|
||||
filetyp TEXT NOT NULL,
|
||||
FOREIGN KEY (elsa_id) REFERENCES elsa (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elsa_media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
work_author TEXT,
|
||||
section_author TEXT,
|
||||
year TEXT,
|
||||
edition TEXT,
|
||||
work_title TEXT,
|
||||
chapter_title TEXT,
|
||||
location TEXT,
|
||||
publisher TEXT,
|
||||
signature TEXT,
|
||||
issue TEXT,
|
||||
pages TEXT,
|
||||
isbn TEXT,
|
||||
type TEXT,
|
||||
elsa_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (elsa_id) REFERENCES elsa (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS neweditions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
new_bookdata TEXT,
|
||||
old_edition_id INTEGER,
|
||||
for_apparat INTEGER,
|
||||
ordered BOOLEAN DEFAULT (0),
|
||||
FOREIGN KEY (old_edition_id) REFERENCES media (id),
|
||||
FOREIGN KEY (for_apparat) REFERENCES semesterapparat (id)
|
||||
);
|
||||
|
||||
-- Helpful indices to speed up frequent lookups and joins
|
||||
CREATE INDEX IF NOT EXISTS idx_media_app_prof ON media(app_id, prof_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_deleted ON media(deleted);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_available ON media(available);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_remind_at ON messages(remind_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_semesterapparat_prof ON semesterapparat(prof_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_semesterapparat_appnr ON semesterapparat(appnr);
|
||||
|
||||
COMMIT;
|
||||
10
src/backend/migrations/V002__create_table_webadis_login.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webadis_login (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE webadis_login
|
||||
ADD COLUMN effective_range TEXT;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,128 +0,0 @@
|
||||
import datetime
|
||||
from src import logger
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Semester:
|
||||
logger.debug("Semester class loaded")
|
||||
|
||||
_year: int | None = str(datetime.datetime.now().year)[2:]
|
||||
_semester: str | None = None
|
||||
_month: int | None = datetime.datetime.now().month
|
||||
value: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self._year, str):
|
||||
self._year = int(self._year)
|
||||
if self._year is None:
|
||||
self._year = datetime.datetime.now().year[2:]
|
||||
if self._month is None:
|
||||
self._month = datetime.datetime.now().month
|
||||
if self._semester is None:
|
||||
self.generateSemester()
|
||||
self.computeValue()
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def generateSemester(self):
|
||||
if self._month < 4 or self._month < 9:
|
||||
self._semester = "WiSe"
|
||||
else:
|
||||
self._semester = "SoSe"
|
||||
|
||||
@logger.catch
|
||||
def computeValue(self):
|
||||
# year is only last two digits
|
||||
year = self._year
|
||||
if self._semester == "WiSe":
|
||||
if self._month < 4:
|
||||
valueyear = str(year - 1) + "/" + str(year)
|
||||
else:
|
||||
valueyear = str(year)
|
||||
self.value = f"{self._semester} {valueyear}"
|
||||
|
||||
@logger.catch
|
||||
def offset(self, value: int) -> str:
|
||||
"""Generate a new Semester object by offsetting the current semester by a given value
|
||||
|
||||
Args:
|
||||
value (int): The value by which the semester should be offset
|
||||
|
||||
Returns:
|
||||
str: the new semester value
|
||||
"""
|
||||
assert isinstance(value, int), "Value must be an integer"
|
||||
if value == 0:
|
||||
return self
|
||||
if value > 0:
|
||||
if value % 2 == 0:
|
||||
return Semester(
|
||||
self._year - value // 2, self._semester - value // 2 + 1
|
||||
)
|
||||
else:
|
||||
semester = self._semester
|
||||
semester = "SoSe" if semester == "WiSe" else "WiSe"
|
||||
return Semester(self._year + value // 2, semester)
|
||||
else:
|
||||
if value % 2 == 0:
|
||||
return Semester(self.year + value // 2, self._semester)
|
||||
else:
|
||||
semester = self._semester
|
||||
semester = "SoSe" if semester == "WiSe" else "WiSe"
|
||||
return Semester(self._year + value // 2, semester)
|
||||
|
||||
def isPastSemester(self, semester) -> bool:
|
||||
"""Checks if the current Semester is a past Semester compared to the given Semester
|
||||
|
||||
Args:
|
||||
semester (str): The semester to compare to
|
||||
|
||||
Returns:
|
||||
bool: True if the current semester is in the past, False otherwise
|
||||
"""
|
||||
if self.year < semester.year:
|
||||
return True
|
||||
if self.year == semester.year:
|
||||
if self.semester == "WiSe" and semester.semester == "SoSe":
|
||||
return True
|
||||
return False
|
||||
|
||||
def isFutureSemester(self, semester: "Semester") -> bool:
|
||||
"""Checks if the current Semester is a future Semester compared to the given Semester
|
||||
|
||||
Args:
|
||||
semester (str): The semester to compare to
|
||||
|
||||
Returns:
|
||||
bool: True if the current semester is in the future, False otherwise
|
||||
"""
|
||||
if self.year > semester.year:
|
||||
return True
|
||||
if self.year == semester.year:
|
||||
if self.semester == "SoSe" and semester.semester == "WiSe":
|
||||
return True
|
||||
return False
|
||||
|
||||
def from_string(self, val):
|
||||
self.value = val
|
||||
self._year = int(val[-2:])
|
||||
self._semester = val[:4]
|
||||
return self
|
||||
|
||||
@property
|
||||
def next(self):
|
||||
return self.offset(1)
|
||||
|
||||
@property
|
||||
def previous(self):
|
||||
return self.offset(-1)
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
return self._year
|
||||
|
||||
@property
|
||||
def semester(self):
|
||||
return self._semester
|
||||
@@ -1,55 +1,60 @@
|
||||
import sqlite3
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from PyQt6.QtCore import QThread
|
||||
from PyQt6.QtCore import pyqtSignal as Signal
|
||||
from src.backend import Database
|
||||
|
||||
from src.logic.webrequest import BibTextTransformer, WebRequest
|
||||
from src.shared.logging import log
|
||||
|
||||
# Logger configured centrally in main; this module just uses `log`
|
||||
|
||||
|
||||
class BookGrabber(QThread):
|
||||
updateSignal = Signal(int, int)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, appnr):
|
||||
def __init__(self):
|
||||
super(BookGrabber, self).__init__(parent=None)
|
||||
self.is_Running = True
|
||||
logger.info("Starting worker thread")
|
||||
self.data = None
|
||||
log.info("Starting worker thread")
|
||||
self.data = []
|
||||
self.app_id = None
|
||||
self.prof_id = None
|
||||
self.mode = None
|
||||
self.book_id = None
|
||||
self.use_any = False
|
||||
self.use_exact = False
|
||||
self.appnr = appnr
|
||||
self.app_nr = None
|
||||
self.tstate = (self.app_id, self.prof_id, self.mode, self.data)
|
||||
self.request = WebRequest()
|
||||
self.db = Database()
|
||||
|
||||
def add_values(self, app_id, prof_id, mode, data, any_book=False, exact=False):
|
||||
def add_values(
|
||||
self, app_id: int, prof_id: int, mode: str, data, any_book=False, exact=False
|
||||
):
|
||||
self.app_id = app_id
|
||||
self.prof_id = prof_id
|
||||
self.mode = mode
|
||||
self.data = data
|
||||
self.data: list[str] = data
|
||||
self.use_any = any_book
|
||||
self.use_exact = exact
|
||||
logger.info(f"Working on {len(self.data)} entries")
|
||||
self.tstate = (self.app_id, self.prof_id, self.mode, self.data)
|
||||
logger.debug("State: " + str(self.tstate))
|
||||
# print(self.tstate)
|
||||
log.info(f"Working on {len(self.data)} entries")
|
||||
self.tstate = (self.app_nr, self.prof_id, self.mode, self.data)
|
||||
log.debug("State: " + str(self.tstate))
|
||||
app_nr = self.db.query_db(
|
||||
"SELECT appnr FROM semesterapparat WHERE id = ?", (self.app_id,)
|
||||
)[0][0]
|
||||
self.request.set_apparat(app_nr)
|
||||
# log.debug(self.tstate)
|
||||
|
||||
def run(self):
|
||||
self.db = Database()
|
||||
item = 0
|
||||
iterdata = self.data
|
||||
# print(iterdata)
|
||||
if self.prof_id is None:
|
||||
self.prof_id = self.db.getProfNameByApparat(self.app_id)
|
||||
for entry in iterdata:
|
||||
# print(entry)
|
||||
signature = str(entry)
|
||||
logger.info("Processing entry: " + signature)
|
||||
# log.debug(iterdata)
|
||||
|
||||
webdata = WebRequest().set_apparat(self.appnr).get_ppn(entry)
|
||||
for entry in iterdata:
|
||||
# log.debug(entry)
|
||||
log.info("Processing entry: {}", entry)
|
||||
|
||||
webdata = self.request.get_ppn(entry)
|
||||
if self.use_any:
|
||||
webdata = webdata.use_any_book
|
||||
webdata = webdata.get_data()
|
||||
@@ -58,12 +63,12 @@ class BookGrabber(QThread):
|
||||
continue
|
||||
|
||||
bd = BibTextTransformer(self.mode)
|
||||
print(webdata)
|
||||
log.debug(webdata)
|
||||
if self.mode == "ARRAY":
|
||||
if self.use_exact:
|
||||
bd = bd.use_signature(entry)
|
||||
bd = bd.get_data(webdata).return_data()
|
||||
print(bd)
|
||||
log.debug(bd)
|
||||
if bd is None:
|
||||
# bd = BookData
|
||||
continue
|
||||
@@ -76,25 +81,29 @@ class BookGrabber(QThread):
|
||||
self.db.addBookToDatabase(bd, self.app_id, self.prof_id)
|
||||
# get latest book id
|
||||
self.book_id = self.db.getLastBookId()
|
||||
logger.info("Added book to database")
|
||||
log.info("Added book to database")
|
||||
state = 0
|
||||
for result in transformer.RDS_DATA:
|
||||
# print(result.RDS_LOCATION)
|
||||
if str(self.app_id) in result.RDS_LOCATION:
|
||||
# log.debug(result.RDS_LOCATION)
|
||||
if str(self.app_nr) in result.RDS_LOCATION:
|
||||
state = 1
|
||||
break
|
||||
|
||||
logger.info(f"State of {signature}: {state}")
|
||||
# print("updating availability of " + str(self.book_id) + " to " + str(state))
|
||||
log.info(f"State of {entry}: {state}")
|
||||
log.debug(
|
||||
"updating availability of " + str(self.book_id) + " to " + str(state)
|
||||
)
|
||||
try:
|
||||
self.db.setAvailability(self.book_id, state)
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.error(f"Failed to update availability: {e}")
|
||||
log.debug("Added book to database")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to update availability: {e}")
|
||||
log.debug("Failed to update availability: " + str(e))
|
||||
|
||||
# time.sleep(5)
|
||||
item += 1
|
||||
self.updateSignal.emit(item, len(self.data))
|
||||
logger.info("Worker thread finished")
|
||||
log.info("Worker thread finished")
|
||||
# self.done.emit()
|
||||
self.quit()
|
||||
|
||||
@@ -102,87 +111,89 @@ class BookGrabber(QThread):
|
||||
self.is_Running = False
|
||||
|
||||
|
||||
# class BookGrabber(object):
|
||||
# updateSignal = Signal(int, int)
|
||||
# done = Signal()
|
||||
class BookGrabberTest(QThread):
|
||||
updateSignal = Signal(int, int)
|
||||
done = Signal()
|
||||
|
||||
# def __init__(self, app_id, prof_id, mode, data, parent=None):
|
||||
# super(BookGrabber, self).__init__(parent=None)
|
||||
# self.is_Running = True
|
||||
# logger = MyLogger("Worker")
|
||||
# logger.info("Starting worker thread")
|
||||
# self.data = data
|
||||
# logger.info(f"Working on {len(self.data)} entries")
|
||||
# self.app_id = app_id
|
||||
# self.prof_id = prof_id
|
||||
# self.mode = mode
|
||||
# self.book_id = None
|
||||
# self.state = (self.app_id, self.prof_id, self.mode, self.data)
|
||||
# # print(self.state)
|
||||
# logger.info("state: " + str(self.state))
|
||||
# # time.sleep(2)
|
||||
def __init__(self, appnr: int):
|
||||
super(BookGrabberTest, self).__init__(parent=None)
|
||||
self.is_Running = True
|
||||
log.info("Starting worker thread")
|
||||
self.data = None
|
||||
self.app_nr = None
|
||||
self.prof_id = None
|
||||
self.mode = None
|
||||
self.book_id = None
|
||||
self.use_any = False
|
||||
self.use_exact = False
|
||||
self.app_nr = appnr
|
||||
self.tstate = (self.app_nr, self.prof_id, self.mode, self.data)
|
||||
self.results = []
|
||||
|
||||
# def resetValues(self):
|
||||
# self.app_id = None
|
||||
# self.prof_id = None
|
||||
# self.mode = None
|
||||
# self.data = None
|
||||
# self.book_id = None
|
||||
def add_values(
|
||||
self, app_nr: int, prof_id: int, mode: str, data, any_book=False, exact=False
|
||||
):
|
||||
self.app_nr = app_nr
|
||||
self.prof_id = prof_id
|
||||
self.mode = mode
|
||||
self.data = data
|
||||
self.use_any = any_book
|
||||
self.use_exact = exact
|
||||
log.info(f"Working on {len(self.data)} entries")
|
||||
self.tstate = (self.app_nr, self.prof_id, self.mode, self.data)
|
||||
log.debug("State: " + str(self.tstate))
|
||||
# log.debug(self.tstate)
|
||||
|
||||
# def run(self):
|
||||
# while self.is_Running:
|
||||
# self.db = Database()
|
||||
# item = 0
|
||||
# iterdata = self.data
|
||||
# # print(iterdata)
|
||||
# for entry in iterdata:
|
||||
# # print(entry)
|
||||
# signature = str(entry)
|
||||
# logger.info("Processing entry: " + signature)
|
||||
def run(self):
|
||||
item = 0
|
||||
iterdata = self.data
|
||||
# log.debug(iterdata)
|
||||
for entry in iterdata:
|
||||
# log.debug(entry)
|
||||
signature = str(entry)
|
||||
log.info("Processing entry: " + signature)
|
||||
|
||||
# webdata = WebRequest().get_ppn(entry).get_data()
|
||||
# if webdata == "error":
|
||||
# continue
|
||||
# bd = BibTextTransformer(self.mode).get_data(webdata).return_data()
|
||||
# transformer = BibTextTransformer("RDS")
|
||||
# rds = transformer.get_data(webdata).return_data("rds_availability")
|
||||
# bd.signature = entry
|
||||
# # confirm lock is acquired
|
||||
# self.db.addBookToDatabase(bd, self.app_id, self.prof_id)
|
||||
# # get latest book id
|
||||
# self.book_id = self.db.getLastBookId()
|
||||
# logger.info("Added book to database")
|
||||
# state = 0
|
||||
# # print(len(rds.items))
|
||||
# for rds_item in rds.items:
|
||||
# sign = rds_item.superlocation
|
||||
# loc = rds_item.location
|
||||
# # logger.debug(sign, loc)
|
||||
# # logger.debug(rds_item)
|
||||
# if self.app_id in sign or self.app_id in loc:
|
||||
# state = 1
|
||||
# break
|
||||
webdata = WebRequest().set_apparat(self.app_nr).get_ppn(entry)
|
||||
if self.use_any:
|
||||
webdata = webdata.use_any_book
|
||||
webdata = webdata.get_data()
|
||||
|
||||
# logger.info(f"State of {signature}: {state}")
|
||||
# # print(
|
||||
# "updating availability of "
|
||||
# + str(self.book_id)
|
||||
# + " to "
|
||||
# + str(state)
|
||||
# )
|
||||
# try:
|
||||
# self.db.setAvailability(self.book_id, state)
|
||||
# except sqlite3.OperationalError as e:
|
||||
# logger.error(f"Failed to update availability: {e}")
|
||||
if webdata == "error":
|
||||
continue
|
||||
|
||||
# # time.sleep(5)
|
||||
# item += 1
|
||||
# self.updateSignal.emit(item, len(self.data))
|
||||
# logger.info("Worker thread finished")
|
||||
# # self.done.emit()
|
||||
# self.stop()
|
||||
# if not self.is_Running:
|
||||
# break
|
||||
bd = BibTextTransformer(self.mode)
|
||||
if self.mode == "ARRAY":
|
||||
if self.use_exact:
|
||||
bd = bd.use_signature(entry)
|
||||
bd = bd.get_data(webdata).return_data()
|
||||
if bd is None:
|
||||
# bd = BookData
|
||||
continue
|
||||
bd.signature = entry
|
||||
transformer = (
|
||||
BibTextTransformer("RDS").get_data(webdata).return_data("rds_data")
|
||||
)
|
||||
|
||||
# def stop(self):
|
||||
# self.is_Running = False
|
||||
# confirm lock is acquired
|
||||
# get latest book id
|
||||
log.info("Added book to database")
|
||||
state = 0
|
||||
for result in transformer.RDS_DATA:
|
||||
# log.debug(result.RDS_LOCATION)
|
||||
if str(self.app_nr) in result.RDS_LOCATION:
|
||||
state = 1
|
||||
break
|
||||
|
||||
log.info(f"State of {signature}: {state}")
|
||||
# log.debug("updating availability of " + str(self.book_id) + " to " + str(state))
|
||||
self.results.append(bd)
|
||||
|
||||
# time.sleep(5)
|
||||
item += 1
|
||||
self.updateSignal.emit(item, len(self.data))
|
||||
log.info("Worker thread finished")
|
||||
# self.done.emit()
|
||||
self.quit()
|
||||
|
||||
def stop(self):
|
||||
self.is_Running = False
|
||||
|
||||
345
src/backend/thread_neweditions.py
Normal file
@@ -0,0 +1,345 @@
|
||||
import os
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from math import ceil
|
||||
from queue import Empty, Queue
|
||||
from time import monotonic # <-- NEW
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
# from src.logic.webrequest import BibTextTransformer, WebRequest
|
||||
from src.backend.catalogue import Catalogue
|
||||
from src.logic import BookData
|
||||
from src.logic.SRU import SWB
|
||||
from src.shared.logging import log
|
||||
|
||||
# use all available cores - 2, but at least 1
|
||||
THREAD_COUNT = max(os.cpu_count() - 2, 1)
|
||||
THREAD_MIN_ITEMS = 5
|
||||
|
||||
# Logger configured centrally in main; use shared `log`
|
||||
|
||||
swb = SWB()
|
||||
dnb = SWB()
|
||||
cat = Catalogue()
|
||||
|
||||
RVK_ALLOWED = r"[A-Z0-9.\-\/]" # conservative RVK character set
|
||||
|
||||
|
||||
def find_newer_edition(
|
||||
swb_result: BookData, dnb_result: List[BookData]
|
||||
) -> Optional[List[BookData]]:
|
||||
"""
|
||||
New edition if:
|
||||
- year > swb.year OR
|
||||
- edition_number > swb.edition_number
|
||||
BUT: discard any candidate with year < swb.year (if both years are known).
|
||||
|
||||
Same-work check:
|
||||
- Compare RVK roots of signatures (after stripping trailing '+N' and '(N)').
|
||||
- If both have signatures and RVKs differ -> skip.
|
||||
|
||||
Preferences (in order):
|
||||
1) RVK matches SWB
|
||||
2) Print over Online-Ressource
|
||||
3) Has signature
|
||||
4) Newer: (year desc, edition_number desc)
|
||||
"""
|
||||
|
||||
def strip_copy_and_edition(s: str) -> str:
|
||||
s = re.sub(r"\(\s*\d+\s*\)", "", s) # remove '(N)'
|
||||
s = re.sub(r"\s*\+\s*\d+\s*$", "", s) # remove trailing '+N'
|
||||
return s
|
||||
|
||||
def extract_rvk_root(sig: Optional[str]) -> str:
|
||||
if not sig:
|
||||
return ""
|
||||
t = strip_copy_and_edition(sig.upper())
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
m = re.match(rf"^([A-Z]{{1,3}}\s*{RVK_ALLOWED}*)", t)
|
||||
if not m:
|
||||
cleaned = re.sub(rf"[^{RVK_ALLOWED} ]+", "", t).strip()
|
||||
return cleaned.split(" ")[0] if cleaned else ""
|
||||
return re.sub(r"\s+", " ", m.group(1)).strip()
|
||||
|
||||
def has_sig(b: BookData) -> bool:
|
||||
return bool(getattr(b, "signature", None))
|
||||
|
||||
def is_online(b: BookData) -> bool:
|
||||
return (getattr(b, "media_type", None) or "").strip() == "Online-Ressource"
|
||||
|
||||
def is_print(b: BookData) -> bool:
|
||||
return not is_online(b)
|
||||
|
||||
def rvk_matches_swb(b: BookData) -> bool:
|
||||
if not has_sig(b) or not has_sig(swb_result):
|
||||
return False
|
||||
return extract_rvk_root(b.signature) == extract_rvk_root(swb_result.signature)
|
||||
|
||||
def strictly_newer(b: BookData) -> bool:
|
||||
# Hard guard: if both years are known and candidate is older, discard
|
||||
if (
|
||||
b.year is not None
|
||||
and swb_result.year is not None
|
||||
and b.year < swb_result.year
|
||||
):
|
||||
return False
|
||||
|
||||
newer_by_year = (
|
||||
b.year is not None
|
||||
and swb_result.year is not None
|
||||
and b.year > swb_result.year
|
||||
)
|
||||
newer_by_edition = (
|
||||
b.edition_number is not None
|
||||
and swb_result.edition_number is not None
|
||||
and b.edition_number > swb_result.edition_number
|
||||
)
|
||||
# Thanks to the guard above, newer_by_edition can't pick something with a smaller year.
|
||||
return newer_by_year or newer_by_edition
|
||||
|
||||
swb_has_sig = has_sig(swb_result)
|
||||
swb_rvk = extract_rvk_root(getattr(swb_result, "signature", None))
|
||||
|
||||
# 1) Filter: same work (by RVK if both have sigs) AND strictly newer
|
||||
candidates: List[BookData] = []
|
||||
for b in dnb_result:
|
||||
if has_sig(b) and swb_has_sig:
|
||||
if extract_rvk_root(b.signature) != swb_rvk:
|
||||
continue # different work
|
||||
if strictly_newer(b):
|
||||
candidates.append(b)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 2) Dedupe by PPN → prefer (rvk-match, is-print, has-signature)
|
||||
def pref_score(x: BookData) -> tuple[int, int, int]:
|
||||
return (
|
||||
1 if rvk_matches_swb(x) else 0,
|
||||
1 if is_print(x) else 0,
|
||||
1 if has_sig(x) else 0,
|
||||
)
|
||||
|
||||
by_ppn: dict[Optional[str], BookData] = {}
|
||||
for b in candidates:
|
||||
key = getattr(b, "ppn", None)
|
||||
prev = by_ppn.get(key)
|
||||
if prev is None or pref_score(b) > pref_score(prev):
|
||||
by_ppn[key] = b
|
||||
|
||||
deduped = list(by_ppn.values())
|
||||
if not deduped:
|
||||
return None
|
||||
|
||||
# 3) Preserve all qualifying newer editions, but order by preference
|
||||
def sort_key(b: BookData):
|
||||
year = b.year if b.year is not None else -1
|
||||
ed = b.edition_number if b.edition_number is not None else -1
|
||||
return (
|
||||
1 if rvk_matches_swb(b) else 0,
|
||||
1 if is_print(b) else 0,
|
||||
1 if has_sig(b) else 0,
|
||||
year,
|
||||
ed,
|
||||
)
|
||||
|
||||
deduped.sort(key=sort_key, reverse=True)
|
||||
return deduped
|
||||
|
||||
|
||||
class NewEditionCheckerThread(QThread):
|
||||
updateSignal = Signal(int, int) # (processed, total)
|
||||
updateProgress = Signal(int, int) # (processed, total)
|
||||
total_entries_signal = Signal(int)
|
||||
resultsSignal = Signal(list) # list[tuple[BookData, list[BookData]]]
|
||||
|
||||
# NEW: metrics signals
|
||||
rateSignal = Signal(float) # items per second ("it/s")
|
||||
etaSignal = Signal(int) # seconds remaining (-1 when unknown)
|
||||
|
||||
def __init__(self, entries: Optional[list["BookData"]] = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.entries: list["BookData"] = entries if entries is not None else []
|
||||
self.results: list[tuple["BookData", list["BookData"]]] = []
|
||||
|
||||
def reset(self):
|
||||
self.entries = []
|
||||
self.results = []
|
||||
|
||||
# ---------- internal helpers ----------
|
||||
|
||||
@staticmethod
|
||||
def _split_evenly(items: list, parts: int) -> list[list]:
|
||||
"""Split items as evenly as possible into `parts` chunks (no empty tails)."""
|
||||
if parts <= 1 or len(items) <= 1:
|
||||
return [items]
|
||||
n = len(items)
|
||||
base = n // parts
|
||||
extra = n % parts
|
||||
chunks = []
|
||||
i = 0
|
||||
for k in range(parts):
|
||||
size = base + (1 if k < extra else 0)
|
||||
if size == 0:
|
||||
continue
|
||||
chunks.append(items[i : i + size])
|
||||
i += size
|
||||
return chunks
|
||||
|
||||
@staticmethod
|
||||
def _clean_title(raw: str) -> str:
|
||||
title = raw.rstrip(" .:,;!?")
|
||||
title = re.sub(r"\s*\(.*\)", "", title)
|
||||
return title.strip()
|
||||
|
||||
@classmethod
|
||||
def _process_book(
|
||||
cls, book: "BookData"
|
||||
) -> tuple["BookData", list["BookData"]] | None:
|
||||
"""Process one book; returns (original, [found editions]) or None on failure."""
|
||||
if not book.title:
|
||||
return None
|
||||
response: list["BookData"] = []
|
||||
query = [
|
||||
f"pica.tit={book.title}",
|
||||
f"pica.vlg={book.publisher}",
|
||||
]
|
||||
|
||||
swb_result = swb.getBooks(["pica.bib=20735", f"pica.ppn={book.ppn}"])[0]
|
||||
dnb_results = swb.getBooks(query)
|
||||
new_editions = find_newer_edition(swb_result, dnb_results)
|
||||
|
||||
if new_editions is not None:
|
||||
for new_edition in new_editions:
|
||||
new_edition.library_location = cat.get_location(new_edition.ppn)
|
||||
try:
|
||||
isbn = (
|
||||
str(new_edition.isbn[0])
|
||||
if isinstance(new_edition.isbn, list)
|
||||
else str(new_edition.isbn)
|
||||
)
|
||||
new_edition.link = (
|
||||
f"https://www.lehmanns.de/search/quick?mediatype_id=2&q={isbn}"
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
isbn = None
|
||||
new_edition.in_library = cat.in_library(new_edition.ppn)
|
||||
response = new_editions
|
||||
|
||||
# client = SWB()
|
||||
# response: list["BookData"] = []
|
||||
# # First, search by title only
|
||||
# results = client.getBooks([f"pica.title={title}", f"pica.vlg={book.publisher}"])
|
||||
|
||||
# lehmanns = LehmannsClient()
|
||||
# results = lehmanns.search_by_title(title)
|
||||
# for result in results:
|
||||
# if "(eBook)" in result.title:
|
||||
# result.title = result.title.replace("(eBook)", "").strip()
|
||||
# swb_results = client.getBooks(
|
||||
# [
|
||||
# f"pica.tit={result.title}",
|
||||
# f"pica.vlg={result.publisher.split(',')[0]}",
|
||||
# ]
|
||||
# )
|
||||
# for swb in swb_results:
|
||||
# if swb.isbn == result.isbn:
|
||||
# result.ppn = swb.ppn
|
||||
# result.signature = swb.signature
|
||||
# response.append(result)
|
||||
# if (result.edition_number < swb.edition_number) and (
|
||||
# swb.year > result.year
|
||||
# ):
|
||||
# response.append(result)
|
||||
if response == []:
|
||||
return None
|
||||
# Remove duplicates based on ppn
|
||||
return (book, response)
|
||||
|
||||
@classmethod
|
||||
def _worker(cls, items: list["BookData"], q: Queue) -> None:
|
||||
"""Worker for one chunk; pushes ('result', ...), ('progress', 1), and ('done', None)."""
|
||||
try:
|
||||
for book in items:
|
||||
try:
|
||||
result = cls._process_book(book)
|
||||
except Exception:
|
||||
result = None
|
||||
if result is not None:
|
||||
q.put(("result", result))
|
||||
q.put(("progress", 1))
|
||||
finally:
|
||||
q.put(("done", None))
|
||||
|
||||
# ---------- thread entry point ----------
|
||||
|
||||
def run(self):
|
||||
total = len(self.entries)
|
||||
self.total_entries_signal.emit(total)
|
||||
|
||||
# start timer for metrics
|
||||
t0 = monotonic()
|
||||
|
||||
if total == 0:
|
||||
log.debug("No entries to process.")
|
||||
# emit metrics (zero work)
|
||||
self.rateSignal.emit(0.0)
|
||||
self.etaSignal.emit(0)
|
||||
self.resultsSignal.emit([])
|
||||
return
|
||||
|
||||
# Up to 4 workers; ~20 items per worker
|
||||
num_workers = min(THREAD_COUNT, max(1, ceil(total / THREAD_MIN_ITEMS)))
|
||||
chunks = self._split_evenly(self.entries, num_workers)
|
||||
sizes = [len(ch) for ch in chunks]
|
||||
|
||||
q: Queue = Queue()
|
||||
processed = 0
|
||||
finished_workers = 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(chunks)) as ex:
|
||||
futures = [ex.submit(self._worker, ch, q) for ch in chunks]
|
||||
|
||||
log.info(
|
||||
f"Launched {len(futures)} worker thread(s) for {total} entries: {sizes} entries per thread."
|
||||
)
|
||||
for idx, sz in enumerate(sizes, 1):
|
||||
log.debug(f"Thread {idx}: {sz} entries")
|
||||
|
||||
# Aggregate progress/results
|
||||
while finished_workers < len(chunks):
|
||||
try:
|
||||
kind, payload = q.get(timeout=0.1)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
if kind == "progress":
|
||||
processed += int(payload)
|
||||
self.updateSignal.emit(processed, total)
|
||||
self.updateProgress.emit(processed, total)
|
||||
|
||||
# ---- NEW: compute & emit metrics ----
|
||||
elapsed = max(1e-9, monotonic() - t0)
|
||||
rate = processed / elapsed # items per second
|
||||
remaining = max(0, total - processed)
|
||||
eta_sec = int(round(remaining / rate)) if rate > 0 else -1
|
||||
|
||||
self.rateSignal.emit(rate)
|
||||
# clamp negative just in case
|
||||
self.etaSignal.emit(max(0, eta_sec) if eta_sec >= 0 else -1)
|
||||
# -------------------------------------
|
||||
|
||||
elif kind == "result":
|
||||
self.results.append(payload)
|
||||
elif kind == "done":
|
||||
finished_workers += 1
|
||||
|
||||
# Final metrics on completion
|
||||
elapsed_total = max(1e-9, monotonic() - t0)
|
||||
final_rate = total / elapsed_total
|
||||
self.rateSignal.emit(final_rate)
|
||||
self.etaSignal.emit(0)
|
||||
|
||||
self.resultsSignal.emit(self.results)
|
||||
@@ -1,11 +1,20 @@
|
||||
import sys
|
||||
import time
|
||||
|
||||
# from icecream import ic
|
||||
from PyQt6.QtCore import QThread
|
||||
from PyQt6.QtCore import pyqtSignal as Signal
|
||||
import loguru
|
||||
|
||||
# from icecream import ic
|
||||
from PySide6.QtCore import QThread
|
||||
from PySide6.QtCore import Signal as Signal
|
||||
|
||||
from src import LOG_DIR
|
||||
from src.backend import Database
|
||||
|
||||
log = loguru.logger
|
||||
log.remove()
|
||||
log.add(sys.stdout, level="INFO")
|
||||
log.add(f"{LOG_DIR}/application.log", rotation="1 MB", retention="10 days")
|
||||
|
||||
|
||||
# from src.transformers import RDS_AVAIL_DATA
|
||||
|
||||
@@ -22,13 +31,13 @@ class AutoAdder(QThread):
|
||||
self.app_id = app_id
|
||||
self.prof_id = prof_id
|
||||
|
||||
# print("Launched AutoAdder")
|
||||
# print(self.data, self.app_id, self.prof_id)
|
||||
# #print("Launched AutoAdder")
|
||||
# #print(self.data, self.app_id, self.prof_id)
|
||||
|
||||
def run(self):
|
||||
self.db = Database()
|
||||
# show the dialog, start the thread to gather data and dynamically update progressbar and listwidget
|
||||
logger.info("Starting worker thread")
|
||||
log.info("Starting worker thread")
|
||||
item = 0
|
||||
for entry in self.data:
|
||||
try:
|
||||
@@ -39,12 +48,12 @@ class AutoAdder(QThread):
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
# print(e)
|
||||
logger.exception(
|
||||
# #print(e)
|
||||
log.exception(
|
||||
f"The query failed with message {e} for signature {entry}"
|
||||
)
|
||||
continue
|
||||
if item == len(self.data):
|
||||
logger.info("Worker thread finished")
|
||||
log.info("Worker thread finished")
|
||||
# teminate thread
|
||||
self.finished.emit()
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import time
|
||||
|
||||
# from icecream import ic
|
||||
from PyQt6.QtCore import QThread
|
||||
from PyQt6.QtCore import pyqtSignal as Signal
|
||||
from PySide6.QtCore import QThread
|
||||
from PySide6.QtCore import Signal as Signal
|
||||
|
||||
from src.backend.database import Database
|
||||
|
||||
from src.logic.webrequest import BibTextTransformer, WebRequest
|
||||
|
||||
# from src.transformers import RDS_AVAIL_DATA
|
||||
from src.backend.webadis import get_book_medianr
|
||||
from src.logic.webrequest import BibTextTransformer, TransformerType, WebRequest
|
||||
from src.shared.logging import log
|
||||
|
||||
|
||||
class AvailChecker(QThread):
|
||||
@@ -16,13 +13,17 @@ class AvailChecker(QThread):
|
||||
updateProgress = Signal(int, int)
|
||||
|
||||
def __init__(
|
||||
self, links: list = None, appnumber: int = None, parent=None, books=list[dict]
|
||||
self,
|
||||
links: list[str] | None = None,
|
||||
appnumber: int | None = None,
|
||||
parent=None,
|
||||
books: list[dict] | None = None,
|
||||
):
|
||||
if links is None:
|
||||
links = []
|
||||
super().__init__(parent)
|
||||
logger.info("Starting worker thread")
|
||||
logger.info(
|
||||
log.info("Starting worker thread")
|
||||
log.info(
|
||||
"Checking availability for "
|
||||
+ str(links)
|
||||
+ " with appnumber "
|
||||
@@ -31,42 +32,52 @@ class AvailChecker(QThread):
|
||||
)
|
||||
self.links = links
|
||||
self.appnumber = appnumber
|
||||
self.books = books
|
||||
logger.info(
|
||||
self.books = books or []
|
||||
log.info(
|
||||
f"Started worker with appnumber: {self.appnumber} and links: {self.links} and {len(self.books)} books..."
|
||||
)
|
||||
time.sleep(2)
|
||||
# Pre-create reusable request and transformer to avoid per-item overhead
|
||||
self._request = WebRequest().set_apparat(self.appnumber)
|
||||
self._rds_transformer = BibTextTransformer(TransformerType.RDS)
|
||||
|
||||
def run(self):
|
||||
self.db = Database()
|
||||
state = 0
|
||||
count = 0
|
||||
for link in self.links:
|
||||
logger.info("Processing entry: " + str(link))
|
||||
data = WebRequest().set_apparat(self.appnumber).get_ppn(link).get_data()
|
||||
transformer = BibTextTransformer("RDS")
|
||||
rds = transformer.get_data(data).return_data("rds_availability")
|
||||
log.info("Processing entry: " + str(link))
|
||||
data = self._request.get_ppn(link).get_data()
|
||||
rds = self._rds_transformer.get_data(data).return_data("rds_availability")
|
||||
|
||||
book_id = None
|
||||
if not rds or not rds.items:
|
||||
log.warning(f"No RDS data found for link {link}")
|
||||
continue
|
||||
for item in rds.items:
|
||||
sign = item.superlocation
|
||||
loc = item.location
|
||||
# # print(item.location)
|
||||
if self.appnumber in sign or self.appnumber in loc:
|
||||
# # #print(item.location)
|
||||
if str(self.appnumber) in sign or str(self.appnumber) in loc:
|
||||
state = 1
|
||||
break
|
||||
for book in self.books:
|
||||
if book["bookdata"].signature == link:
|
||||
book_id = book["id"]
|
||||
break
|
||||
logger.info(f"State of {link}: " + str(state))
|
||||
# print("Updating availability of " + str(book_id) + " to " + str(state))
|
||||
log.info(f"State of {link}: " + str(state))
|
||||
# #print("Updating availability of " + str(book_id) + " to " + str(state))
|
||||
# use get_book_medianr to update the medianr of the book in the database
|
||||
auth = self.db.getWebADISAuth
|
||||
medianr = get_book_medianr(rds.items[0].callnumber, self.appnumber, auth)
|
||||
book_data = book["bookdata"]
|
||||
book_data.medianr = medianr
|
||||
self.db.updateBookdata(book["id"], book_data)
|
||||
self.db.setAvailability(book_id, state)
|
||||
count += 1
|
||||
self.updateProgress.emit(count, len(self.links))
|
||||
self.updateSignal.emit(item.callnumber, state)
|
||||
|
||||
logger.info("Worker thread finished")
|
||||
log.info("Worker thread finished")
|
||||
# teminate thread
|
||||
|
||||
self.quit()
|
||||
|
||||
35
src/backend/webadis.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
def get_book_medianr(signature: str, semesterapparat_nr: int, auth: tuple) -> str:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.goto(
|
||||
"https://bsz.ibs-bw.de:22998/aDISWeb/app?service=direct/0/Home/$DirectLink&sp=SDAP42"
|
||||
)
|
||||
page.get_by_role("textbox", name="Benutzer").fill(auth[0])
|
||||
page.get_by_role("textbox", name="Benutzer").press("Tab")
|
||||
page.get_by_role("textbox", name="Kennwort").fill(auth[1])
|
||||
page.get_by_role("textbox", name="Kennwort").press("Enter")
|
||||
page.get_by_role("button", name="Katalog").click()
|
||||
page.get_by_role("textbox", name="Signatur").click()
|
||||
page.get_by_role("textbox", name="Signatur").fill(signature)
|
||||
page.get_by_role("textbox", name="Signatur").press("Enter")
|
||||
book_list = page.locator("iframe").content_frame.get_by_role(
|
||||
"cell", name="Bibliothek der Pädagogischen"
|
||||
)
|
||||
# this will always find one result, we need to split the resulting text based on the entries that start with "* "
|
||||
book_entries = book_list.inner_text().split("\n")
|
||||
books = []
|
||||
for entry in book_entries:
|
||||
if entry.startswith("* "):
|
||||
books.append(entry)
|
||||
for book in books:
|
||||
if f"Semesterapparat: {semesterapparat_nr}" in book:
|
||||
return book.split("* ")[1].split(":")[0]
|
||||
|
||||
# ---------------------
|
||||
context.close()
|
||||
browser.close()
|
||||
16
src/background/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Background tasks and threading operations."""
|
||||
|
||||
from .autoadder import AutoAdder
|
||||
from .availability_checker import AvailChecker
|
||||
from .book_grabber import BookGrabber, BookGrabberTest
|
||||
from .new_editions import NewEditionCheckerThread
|
||||
from .documentation_server import DocumentationThread
|
||||
|
||||
__all__ = [
|
||||
"AutoAdder",
|
||||
"AvailChecker",
|
||||
"BookGrabber",
|
||||
"BookGrabberTest",
|
||||
"NewEditionCheckerThread",
|
||||
"DocumentationThread",
|
||||
]
|
||||
59
src/background/autoadder.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import sys
|
||||
import time
|
||||
|
||||
import loguru
|
||||
|
||||
# from icecream import ic
|
||||
from PySide6.QtCore import QThread
|
||||
from PySide6.QtCore import Signal as Signal
|
||||
|
||||
from src import LOG_DIR
|
||||
from src.database import Database
|
||||
|
||||
log = loguru.logger
|
||||
log.remove()
|
||||
log.add(sys.stdout, level="INFO")
|
||||
log.add(f"{LOG_DIR}/application.log", rotation="1 MB", retention="10 days")
|
||||
|
||||
|
||||
# from src.transformers import RDS_AVAIL_DATA
|
||||
|
||||
|
||||
class AutoAdder(QThread):
|
||||
updateSignal = Signal(int)
|
||||
|
||||
setTextSignal = Signal(int)
|
||||
progress = Signal(int)
|
||||
|
||||
def __init__(self, data=None, app_id=None, prof_id=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.data = data
|
||||
self.app_id = app_id
|
||||
self.prof_id = prof_id
|
||||
|
||||
# #print("Launched AutoAdder")
|
||||
# #print(self.data, self.app_id, self.prof_id)
|
||||
|
||||
def run(self):
|
||||
self.db = Database()
|
||||
# show the dialog, start the thread to gather data and dynamically update progressbar and listwidget
|
||||
log.info("Starting worker thread")
|
||||
item = 0
|
||||
for entry in self.data:
|
||||
try:
|
||||
self.updateSignal.emit(item)
|
||||
self.setTextSignal.emit(entry)
|
||||
item += 1
|
||||
self.progress.emit(item)
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
# #print(e)
|
||||
log.exception(
|
||||
f"The query failed with message {e} for signature {entry}"
|
||||
)
|
||||
continue
|
||||
if item == len(self.data):
|
||||
log.info("Worker thread finished")
|
||||
# teminate thread
|
||||
self.finished.emit()
|
||||
83
src/background/availability_checker.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# from icecream import ic
|
||||
from PySide6.QtCore import QThread
|
||||
from PySide6.QtCore import Signal as Signal
|
||||
|
||||
from src.database import Database
|
||||
from src.services.webadis import get_book_medianr
|
||||
from src.services.webrequest import BibTextTransformer, TransformerType, WebRequest
|
||||
from src.shared.logging import log
|
||||
|
||||
|
||||
class AvailChecker(QThread):
|
||||
updateSignal = Signal(str, int)
|
||||
updateProgress = Signal(int, int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
links: list[str] | None = None,
|
||||
appnumber: int | None = None,
|
||||
parent=None,
|
||||
books: list[dict] | None = None,
|
||||
):
|
||||
if links is None:
|
||||
links = []
|
||||
super().__init__(parent)
|
||||
log.info("Starting worker thread")
|
||||
log.info(
|
||||
"Checking availability for "
|
||||
+ str(links)
|
||||
+ " with appnumber "
|
||||
+ str(appnumber)
|
||||
+ "..."
|
||||
)
|
||||
self.links = links
|
||||
self.appnumber = appnumber
|
||||
self.books = books or []
|
||||
log.info(
|
||||
f"Started worker with appnumber: {self.appnumber} and links: {self.links} and {len(self.books)} books..."
|
||||
)
|
||||
# Pre-create reusable request and transformer to avoid per-item overhead
|
||||
self._request = WebRequest().set_apparat(self.appnumber)
|
||||
self._rds_transformer = BibTextTransformer(TransformerType.RDS)
|
||||
|
||||
def run(self):
|
||||
self.db = Database()
|
||||
state = 0
|
||||
count = 0
|
||||
for link in self.links:
|
||||
log.info("Processing entry: " + str(link))
|
||||
data = self._request.get_ppn(link).get_data()
|
||||
rds = self._rds_transformer.get_data(data).return_data("rds_availability")
|
||||
|
||||
book_id = None
|
||||
if not rds or not rds.items:
|
||||
log.warning(f"No RDS data found for link {link}")
|
||||
continue
|
||||
for item in rds.items:
|
||||
sign = item.superlocation
|
||||
loc = item.location
|
||||
# # #print(item.location)
|
||||
if str(self.appnumber) in sign or str(self.appnumber) in loc:
|
||||
state = 1
|
||||
break
|
||||
for book in self.books:
|
||||
if book["bookdata"].signature == link:
|
||||
book_id = book["id"]
|
||||
break
|
||||
log.info(f"State of {link}: " + str(state))
|
||||
# #print("Updating availability of " + str(book_id) + " to " + str(state))
|
||||
# use get_book_medianr to update the medianr of the book in the database
|
||||
auth = self.db.getWebADISAuth
|
||||
medianr = get_book_medianr(rds.items[0].callnumber, self.appnumber, auth)
|
||||
book_data = book["bookdata"]
|
||||
book_data.medianr = medianr
|
||||
self.db.updateBookdata(book["id"], book_data)
|
||||
self.db.setAvailability(book_id, state)
|
||||
count += 1
|
||||
self.updateProgress.emit(count, len(self.links))
|
||||
self.updateSignal.emit(item.callnumber, state)
|
||||
|
||||
log.info("Worker thread finished")
|
||||
# teminate thread
|
||||
|
||||
self.quit()
|
||||
199
src/background/book_grabber.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from src.database import Database
|
||||
from src.services.webrequest import BibTextTransformer, WebRequest
|
||||
from src.shared.logging import log
|
||||
|
||||
# Logger configured centrally in main; this module just uses `log`
|
||||
|
||||
|
||||
class BookGrabber(QThread):
|
||||
updateSignal = Signal(int, int)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super(BookGrabber, self).__init__(parent=None)
|
||||
self.is_Running = True
|
||||
log.info("Starting worker thread")
|
||||
self.data = []
|
||||
self.app_id = None
|
||||
self.prof_id = None
|
||||
self.mode = None
|
||||
self.book_id = None
|
||||
self.use_any = False
|
||||
self.use_exact = False
|
||||
self.app_nr = None
|
||||
self.tstate = (self.app_id, self.prof_id, self.mode, self.data)
|
||||
self.request = WebRequest()
|
||||
self.db = Database()
|
||||
|
||||
def add_values(
|
||||
self, app_id: int, prof_id: int, mode: str, data, any_book=False, exact=False
|
||||
):
|
||||
self.app_id = app_id
|
||||
self.prof_id = prof_id
|
||||
self.mode = mode
|
||||
self.data: list[str] = data
|
||||
self.use_any = any_book
|
||||
self.use_exact = exact
|
||||
log.info(f"Working on {len(self.data)} entries")
|
||||
self.tstate = (self.app_nr, self.prof_id, self.mode, self.data)
|
||||
log.debug("State: " + str(self.tstate))
|
||||
app_nr = self.db.query_db(
|
||||
"SELECT appnr FROM semesterapparat WHERE id = ?", (self.app_id,)
|
||||
)[0][0]
|
||||
self.request.set_apparat(app_nr)
|
||||
# log.debug(self.tstate)
|
||||
|
||||
def run(self):
|
||||
item = 0
|
||||
iterdata = self.data
|
||||
# log.debug(iterdata)
|
||||
|
||||
for entry in iterdata:
|
||||
# log.debug(entry)
|
||||
log.info("Processing entry: {}", entry)
|
||||
|
||||
webdata = self.request.get_ppn(entry)
|
||||
if self.use_any:
|
||||
webdata = webdata.use_any_book
|
||||
webdata = webdata.get_data()
|
||||
|
||||
if webdata == "error":
|
||||
continue
|
||||
|
||||
bd = BibTextTransformer(self.mode)
|
||||
log.debug(webdata)
|
||||
if self.mode == "ARRAY":
|
||||
if self.use_exact:
|
||||
bd = bd.use_signature(entry)
|
||||
bd = bd.get_data(webdata).return_data()
|
||||
log.debug(bd)
|
||||
if bd is None:
|
||||
# bd = BookData
|
||||
continue
|
||||
bd.signature = entry
|
||||
transformer = (
|
||||
BibTextTransformer("RDS").get_data(webdata).return_data("rds_data")
|
||||
)
|
||||
|
||||
# confirm lock is acquired
|
||||
self.db.addBookToDatabase(bd, self.app_id, self.prof_id)
|
||||
# get latest book id
|
||||
self.book_id = self.db.getLastBookId()
|
||||
log.info("Added book to database")
|
||||
state = 0
|
||||
for result in transformer.RDS_DATA:
|
||||
# log.debug(result.RDS_LOCATION)
|
||||
if str(self.app_nr) in result.RDS_LOCATION:
|
||||
state = 1
|
||||
break
|
||||
|
||||
log.info(f"State of {entry}: {state}")
|
||||
log.debug(
|
||||
"updating availability of " + str(self.book_id) + " to " + str(state)
|
||||
)
|
||||
try:
|
||||
self.db.setAvailability(self.book_id, state)
|
||||
log.debug("Added book to database")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to update availability: {e}")
|
||||
log.debug("Failed to update availability: " + str(e))
|
||||
|
||||
# time.sleep(5)
|
||||
item += 1
|
||||
self.updateSignal.emit(item, len(self.data))
|
||||
log.info("Worker thread finished")
|
||||
# self.done.emit()
|
||||
self.quit()
|
||||
|
||||
def stop(self):
|
||||
self.is_Running = False
|
||||
|
||||
|
||||
class BookGrabberTest(QThread):
|
||||
updateSignal = Signal(int, int)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, appnr: int):
|
||||
super(BookGrabberTest, self).__init__(parent=None)
|
||||
self.is_Running = True
|
||||
log.info("Starting worker thread")
|
||||
self.data = None
|
||||
self.app_nr = None
|
||||
self.prof_id = None
|
||||
self.mode = None
|
||||
self.book_id = None
|
||||
self.use_any = False
|
||||
self.use_exact = False
|
||||
self.app_nr = appnr
|
||||
self.tstate = (self.app_nr, self.prof_id, self.mode, self.data)
|
||||
self.results = []
|
||||
|
||||
def add_values(
|
||||
self, app_nr: int, prof_id: int, mode: str, data, any_book=False, exact=False
|
||||
):
|
||||
self.app_nr = app_nr
|
||||
self.prof_id = prof_id
|
||||
self.mode = mode
|
||||
self.data = data
|
||||
self.use_any = any_book
|
||||
self.use_exact = exact
|
||||
log.info(f"Working on {len(self.data)} entries")
|
||||
self.tstate = (self.app_nr, self.prof_id, self.mode, self.data)
|
||||
log.debug("State: " + str(self.tstate))
|
||||
# log.debug(self.tstate)
|
||||
|
||||
def run(self):
|
||||
item = 0
|
||||
iterdata = self.data
|
||||
# log.debug(iterdata)
|
||||
for entry in iterdata:
|
||||
# log.debug(entry)
|
||||
signature = str(entry)
|
||||
log.info("Processing entry: " + signature)
|
||||
|
||||
webdata = WebRequest().set_apparat(self.app_nr).get_ppn(entry)
|
||||
if self.use_any:
|
||||
webdata = webdata.use_any_book
|
||||
webdata = webdata.get_data()
|
||||
|
||||
if webdata == "error":
|
||||
continue
|
||||
|
||||
bd = BibTextTransformer(self.mode)
|
||||
if self.mode == "ARRAY":
|
||||
if self.use_exact:
|
||||
bd = bd.use_signature(entry)
|
||||
bd = bd.get_data(webdata).return_data()
|
||||
if bd is None:
|
||||
# bd = BookData
|
||||
continue
|
||||
bd.signature = entry
|
||||
transformer = (
|
||||
BibTextTransformer("RDS").get_data(webdata).return_data("rds_data")
|
||||
)
|
||||
|
||||
# confirm lock is acquired
|
||||
# get latest book id
|
||||
log.info("Added book to database")
|
||||
state = 0
|
||||
for result in transformer.RDS_DATA:
|
||||
# log.debug(result.RDS_LOCATION)
|
||||
if str(self.app_nr) in result.RDS_LOCATION:
|
||||
state = 1
|
||||
break
|
||||
|
||||
log.info(f"State of {signature}: {state}")
|
||||
# log.debug("updating availability of " + str(self.book_id) + " to " + str(state))
|
||||
self.results.append(bd)
|
||||
|
||||
# time.sleep(5)
|
||||
item += 1
|
||||
self.updateSignal.emit(item, len(self.data))
|
||||
log.info("Worker thread finished")
|
||||
# self.done.emit()
|
||||
self.quit()
|
||||
|
||||
def stop(self):
|
||||
self.is_Running = False
|
||||
23
src/background/documentation_server.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from PySide6.QtCore import QThread, Slot
|
||||
from src.utils.documentation import website, QuietHandler
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
|
||||
class DocumentationThread(QThread):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._server = None # store server so we can shut it down
|
||||
|
||||
def run(self):
|
||||
# launch_documentation()
|
||||
self._server = make_server(
|
||||
"localhost", 8000, website(), handler_class=QuietHandler
|
||||
)
|
||||
while not self.isInterruptionRequested():
|
||||
self._server.handle_request()
|
||||
|
||||
@Slot() # slot you can connect to aboutToQuit
|
||||
def stop(self):
|
||||
self.requestInterruption() # ask the loop above to exit
|
||||
if self._server:
|
||||
self._server.shutdown() # unblock handle_request()
|
||||
345
src/background/new_editions.py
Normal file
@@ -0,0 +1,345 @@
|
||||
import os
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from math import ceil
|
||||
from queue import Empty, Queue
|
||||
from time import monotonic # <-- NEW
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
# from src.services.webrequest import BibTextTransformer, WebRequest
|
||||
from src.services.catalogue import Catalogue
|
||||
from src.core.models import BookData
|
||||
from src.services.sru import SWB
|
||||
from src.shared.logging import log
|
||||
|
||||
# use all available cores - 2, but at least 1
|
||||
THREAD_COUNT = max(os.cpu_count() - 2, 1)
|
||||
THREAD_MIN_ITEMS = 5
|
||||
|
||||
# Logger configured centrally in main; use shared `log`
|
||||
|
||||
swb = SWB()
|
||||
dnb = SWB()
|
||||
cat = Catalogue()
|
||||
|
||||
RVK_ALLOWED = r"[A-Z0-9.\-\/]" # conservative RVK character set
|
||||
|
||||
|
||||
def find_newer_edition(
|
||||
swb_result: BookData, dnb_result: List[BookData]
|
||||
) -> Optional[List[BookData]]:
|
||||
"""
|
||||
New edition if:
|
||||
- year > swb.year OR
|
||||
- edition_number > swb.edition_number
|
||||
BUT: discard any candidate with year < swb.year (if both years are known).
|
||||
|
||||
Same-work check:
|
||||
- Compare RVK roots of signatures (after stripping trailing '+N' and '(N)').
|
||||
- If both have signatures and RVKs differ -> skip.
|
||||
|
||||
Preferences (in order):
|
||||
1) RVK matches SWB
|
||||
2) Print over Online-Ressource
|
||||
3) Has signature
|
||||
4) Newer: (year desc, edition_number desc)
|
||||
"""
|
||||
|
||||
def strip_copy_and_edition(s: str) -> str:
|
||||
s = re.sub(r"\(\s*\d+\s*\)", "", s) # remove '(N)'
|
||||
s = re.sub(r"\s*\+\s*\d+\s*$", "", s) # remove trailing '+N'
|
||||
return s
|
||||
|
||||
def extract_rvk_root(sig: Optional[str]) -> str:
|
||||
if not sig:
|
||||
return ""
|
||||
t = strip_copy_and_edition(sig.upper())
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
m = re.match(rf"^([A-Z]{{1,3}}\s*{RVK_ALLOWED}*)", t)
|
||||
if not m:
|
||||
cleaned = re.sub(rf"[^{RVK_ALLOWED} ]+", "", t).strip()
|
||||
return cleaned.split(" ")[0] if cleaned else ""
|
||||
return re.sub(r"\s+", " ", m.group(1)).strip()
|
||||
|
||||
def has_sig(b: BookData) -> bool:
|
||||
return bool(getattr(b, "signature", None))
|
||||
|
||||
def is_online(b: BookData) -> bool:
|
||||
return (getattr(b, "media_type", None) or "").strip() == "Online-Ressource"
|
||||
|
||||
def is_print(b: BookData) -> bool:
|
||||
return not is_online(b)
|
||||
|
||||
def rvk_matches_swb(b: BookData) -> bool:
|
||||
if not has_sig(b) or not has_sig(swb_result):
|
||||
return False
|
||||
return extract_rvk_root(b.signature) == extract_rvk_root(swb_result.signature)
|
||||
|
||||
def strictly_newer(b: BookData) -> bool:
|
||||
# Hard guard: if both years are known and candidate is older, discard
|
||||
if (
|
||||
b.year is not None
|
||||
and swb_result.year is not None
|
||||
and b.year < swb_result.year
|
||||
):
|
||||
return False
|
||||
|
||||
newer_by_year = (
|
||||
b.year is not None
|
||||
and swb_result.year is not None
|
||||
and b.year > swb_result.year
|
||||
)
|
||||
newer_by_edition = (
|
||||
b.edition_number is not None
|
||||
and swb_result.edition_number is not None
|
||||
and b.edition_number > swb_result.edition_number
|
||||
)
|
||||
# Thanks to the guard above, newer_by_edition can't pick something with a smaller year.
|
||||
return newer_by_year or newer_by_edition
|
||||
|
||||
swb_has_sig = has_sig(swb_result)
|
||||
swb_rvk = extract_rvk_root(getattr(swb_result, "signature", None))
|
||||
|
||||
# 1) Filter: same work (by RVK if both have sigs) AND strictly newer
|
||||
candidates: List[BookData] = []
|
||||
for b in dnb_result:
|
||||
if has_sig(b) and swb_has_sig:
|
||||
if extract_rvk_root(b.signature) != swb_rvk:
|
||||
continue # different work
|
||||
if strictly_newer(b):
|
||||
candidates.append(b)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 2) Dedupe by PPN → prefer (rvk-match, is-print, has-signature)
|
||||
def pref_score(x: BookData) -> tuple[int, int, int]:
|
||||
return (
|
||||
1 if rvk_matches_swb(x) else 0,
|
||||
1 if is_print(x) else 0,
|
||||
1 if has_sig(x) else 0,
|
||||
)
|
||||
|
||||
by_ppn: dict[Optional[str], BookData] = {}
|
||||
for b in candidates:
|
||||
key = getattr(b, "ppn", None)
|
||||
prev = by_ppn.get(key)
|
||||
if prev is None or pref_score(b) > pref_score(prev):
|
||||
by_ppn[key] = b
|
||||
|
||||
deduped = list(by_ppn.values())
|
||||
if not deduped:
|
||||
return None
|
||||
|
||||
# 3) Preserve all qualifying newer editions, but order by preference
|
||||
def sort_key(b: BookData):
|
||||
year = b.year if b.year is not None else -1
|
||||
ed = b.edition_number if b.edition_number is not None else -1
|
||||
return (
|
||||
1 if rvk_matches_swb(b) else 0,
|
||||
1 if is_print(b) else 0,
|
||||
1 if has_sig(b) else 0,
|
||||
year,
|
||||
ed,
|
||||
)
|
||||
|
||||
deduped.sort(key=sort_key, reverse=True)
|
||||
return deduped
|
||||
|
||||
|
||||
class NewEditionCheckerThread(QThread):
|
||||
updateSignal = Signal(int, int) # (processed, total)
|
||||
updateProgress = Signal(int, int) # (processed, total)
|
||||
total_entries_signal = Signal(int)
|
||||
resultsSignal = Signal(list) # list[tuple[BookData, list[BookData]]]
|
||||
|
||||
# NEW: metrics signals
|
||||
rateSignal = Signal(float) # items per second ("it/s")
|
||||
etaSignal = Signal(int) # seconds remaining (-1 when unknown)
|
||||
|
||||
def __init__(self, entries: Optional[list["BookData"]] = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.entries: list["BookData"] = entries if entries is not None else []
|
||||
self.results: list[tuple["BookData", list["BookData"]]] = []
|
||||
|
||||
def reset(self):
|
||||
self.entries = []
|
||||
self.results = []
|
||||
|
||||
# ---------- internal helpers ----------
|
||||
|
||||
@staticmethod
|
||||
def _split_evenly(items: list, parts: int) -> list[list]:
|
||||
"""Split items as evenly as possible into `parts` chunks (no empty tails)."""
|
||||
if parts <= 1 or len(items) <= 1:
|
||||
return [items]
|
||||
n = len(items)
|
||||
base = n // parts
|
||||
extra = n % parts
|
||||
chunks = []
|
||||
i = 0
|
||||
for k in range(parts):
|
||||
size = base + (1 if k < extra else 0)
|
||||
if size == 0:
|
||||
continue
|
||||
chunks.append(items[i : i + size])
|
||||
i += size
|
||||
return chunks
|
||||
|
||||
@staticmethod
|
||||
def _clean_title(raw: str) -> str:
|
||||
title = raw.rstrip(" .:,;!?")
|
||||
title = re.sub(r"\s*\(.*\)", "", title)
|
||||
return title.strip()
|
||||
|
||||
@classmethod
|
||||
def _process_book(
|
||||
cls, book: "BookData"
|
||||
) -> tuple["BookData", list["BookData"]] | None:
|
||||
"""Process one book; returns (original, [found editions]) or None on failure."""
|
||||
if not book.title:
|
||||
return None
|
||||
response: list["BookData"] = []
|
||||
query = [
|
||||
f"pica.tit={book.title}",
|
||||
f"pica.vlg={book.publisher}",
|
||||
]
|
||||
|
||||
swb_result = swb.getBooks(["pica.bib=20735", f"pica.ppn={book.ppn}"])[0]
|
||||
dnb_results = swb.getBooks(query)
|
||||
new_editions = find_newer_edition(swb_result, dnb_results)
|
||||
|
||||
if new_editions is not None:
|
||||
for new_edition in new_editions:
|
||||
new_edition.library_location = cat.get_location(new_edition.ppn)
|
||||
try:
|
||||
isbn = (
|
||||
str(new_edition.isbn[0])
|
||||
if isinstance(new_edition.isbn, list)
|
||||
else str(new_edition.isbn)
|
||||
)
|
||||
new_edition.link = (
|
||||
f"https://www.lehmanns.de/search/quick?mediatype_id=2&q={isbn}"
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
isbn = None
|
||||
new_edition.in_library = cat.in_library(new_edition.ppn)
|
||||
response = new_editions
|
||||
|
||||
# client = SWB()
|
||||
# response: list["BookData"] = []
|
||||
# # First, search by title only
|
||||
# results = client.getBooks([f"pica.title={title}", f"pica.vlg={book.publisher}"])
|
||||
|
||||
# lehmanns = LehmannsClient()
|
||||
# results = lehmanns.search_by_title(title)
|
||||
# for result in results:
|
||||
# if "(eBook)" in result.title:
|
||||
# result.title = result.title.replace("(eBook)", "").strip()
|
||||
# swb_results = client.getBooks(
|
||||
# [
|
||||
# f"pica.tit={result.title}",
|
||||
# f"pica.vlg={result.publisher.split(',')[0]}",
|
||||
# ]
|
||||
# )
|
||||
# for swb in swb_results:
|
||||
# if swb.isbn == result.isbn:
|
||||
# result.ppn = swb.ppn
|
||||
# result.signature = swb.signature
|
||||
# response.append(result)
|
||||
# if (result.edition_number < swb.edition_number) and (
|
||||
# swb.year > result.year
|
||||
# ):
|
||||
# response.append(result)
|
||||
if response == []:
|
||||
return None
|
||||
# Remove duplicates based on ppn
|
||||
return (book, response)
|
||||
|
||||
@classmethod
|
||||
def _worker(cls, items: list["BookData"], q: Queue) -> None:
|
||||
"""Worker for one chunk; pushes ('result', ...), ('progress', 1), and ('done', None)."""
|
||||
try:
|
||||
for book in items:
|
||||
try:
|
||||
result = cls._process_book(book)
|
||||
except Exception:
|
||||
result = None
|
||||
if result is not None:
|
||||
q.put(("result", result))
|
||||
q.put(("progress", 1))
|
||||
finally:
|
||||
q.put(("done", None))
|
||||
|
||||
# ---------- thread entry point ----------
|
||||
|
||||
def run(self):
|
||||
total = len(self.entries)
|
||||
self.total_entries_signal.emit(total)
|
||||
|
||||
# start timer for metrics
|
||||
t0 = monotonic()
|
||||
|
||||
if total == 0:
|
||||
log.debug("No entries to process.")
|
||||
# emit metrics (zero work)
|
||||
self.rateSignal.emit(0.0)
|
||||
self.etaSignal.emit(0)
|
||||
self.resultsSignal.emit([])
|
||||
return
|
||||
|
||||
# Up to 4 workers; ~20 items per worker
|
||||
num_workers = min(THREAD_COUNT, max(1, ceil(total / THREAD_MIN_ITEMS)))
|
||||
chunks = self._split_evenly(self.entries, num_workers)
|
||||
sizes = [len(ch) for ch in chunks]
|
||||
|
||||
q: Queue = Queue()
|
||||
processed = 0
|
||||
finished_workers = 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(chunks)) as ex:
|
||||
futures = [ex.submit(self._worker, ch, q) for ch in chunks]
|
||||
|
||||
log.info(
|
||||
f"Launched {len(futures)} worker thread(s) for {total} entries: {sizes} entries per thread."
|
||||
)
|
||||
for idx, sz in enumerate(sizes, 1):
|
||||
log.debug(f"Thread {idx}: {sz} entries")
|
||||
|
||||
# Aggregate progress/results
|
||||
while finished_workers < len(chunks):
|
||||
try:
|
||||
kind, payload = q.get(timeout=0.1)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
if kind == "progress":
|
||||
processed += int(payload)
|
||||
self.updateSignal.emit(processed, total)
|
||||
self.updateProgress.emit(processed, total)
|
||||
|
||||
# ---- NEW: compute & emit metrics ----
|
||||
elapsed = max(1e-9, monotonic() - t0)
|
||||
rate = processed / elapsed # items per second
|
||||
remaining = max(0, total - processed)
|
||||
eta_sec = int(round(remaining / rate)) if rate > 0 else -1
|
||||
|
||||
self.rateSignal.emit(rate)
|
||||
# clamp negative just in case
|
||||
self.etaSignal.emit(max(0, eta_sec) if eta_sec >= 0 else -1)
|
||||
# -------------------------------------
|
||||
|
||||
elif kind == "result":
|
||||
self.results.append(payload)
|
||||
elif kind == "done":
|
||||
finished_workers += 1
|
||||
|
||||
# Final metrics on completion
|
||||
elapsed_total = max(1e-9, monotonic() - t0)
|
||||
final_rate = total / elapsed_total
|
||||
self.rateSignal.emit(final_rate)
|
||||
self.etaSignal.emit(0)
|
||||
|
||||
self.resultsSignal.emit(self.results)
|
||||
30
src/core/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Core domain models and business constants."""
|
||||
|
||||
from .models import (
|
||||
Apparat,
|
||||
ApparatData,
|
||||
Book,
|
||||
BookData,
|
||||
ELSA,
|
||||
MailData,
|
||||
Prof,
|
||||
SemapDocument,
|
||||
Subjects,
|
||||
XMLMailSubmission,
|
||||
)
|
||||
from .constants import *
|
||||
from .semester import Semester
|
||||
|
||||
__all__ = [
|
||||
"Apparat",
|
||||
"ApparatData",
|
||||
"Book",
|
||||
"BookData",
|
||||
"ELSA",
|
||||
"MailData",
|
||||
"Prof",
|
||||
"SemapDocument",
|
||||
"Subjects",
|
||||
"XMLMailSubmission",
|
||||
"Semester",
|
||||
]
|
||||
213
src/core/constants.py
Normal file
@@ -0,0 +1,213 @@
|
||||
APP_NRS = [i for i in range(1, 181)]
|
||||
|
||||
PROF_TITLES = [
|
||||
"Dr. mult.",
|
||||
"Dr. paed.",
|
||||
"Dr. rer. pol.",
|
||||
"Dr. sc. techn.",
|
||||
"Drs.",
|
||||
"Dr. agr.",
|
||||
"Dr. habil.",
|
||||
"Dr. oec.",
|
||||
"Dr. med.",
|
||||
"Dr. e. h.",
|
||||
"Dr. oec. publ.",
|
||||
"Dr. -Ing.",
|
||||
"Dr. theol.",
|
||||
"Dr. med. vet.",
|
||||
"Dr. ing.",
|
||||
"Dr. rer. nat.",
|
||||
"Dr. des.",
|
||||
"Dr. sc. mus.",
|
||||
"Dr. h. c.",
|
||||
"Dr. pharm.",
|
||||
"Dr. med. dent.",
|
||||
"Dr. phil. nat.",
|
||||
"Dr. phil.",
|
||||
"Dr. iur.",
|
||||
"Dr.",
|
||||
"Kein Titel",
|
||||
]
|
||||
|
||||
SEMAP_MEDIA_ACCOUNTS = {
|
||||
1: "1008000055",
|
||||
2: "1008000188",
|
||||
3: "1008000211",
|
||||
4: "1008000344",
|
||||
5: "1008000477",
|
||||
6: "1008000500",
|
||||
7: "1008000633",
|
||||
8: "1008000766",
|
||||
9: "1008000899",
|
||||
10: "1008000922",
|
||||
11: "1008001044",
|
||||
12: "1008001177",
|
||||
13: "1008001200",
|
||||
14: "1008001333",
|
||||
15: "1008001466",
|
||||
16: "1008001599",
|
||||
17: "1008001622",
|
||||
18: "1008001755",
|
||||
19: "1008001888",
|
||||
20: "1008001911",
|
||||
21: "1008002033",
|
||||
22: "1008002166",
|
||||
23: "1008002299",
|
||||
24: "1008002322",
|
||||
25: "1008002455",
|
||||
26: "1008002588",
|
||||
27: "1008002611",
|
||||
28: "1008002744",
|
||||
29: "1008002877",
|
||||
30: "1008002900",
|
||||
31: "1008003022",
|
||||
32: "1008003155",
|
||||
33: "1008003288",
|
||||
34: "1008003311",
|
||||
35: "1008003444",
|
||||
36: "1008003577",
|
||||
37: "1008003600",
|
||||
38: "1008003733",
|
||||
39: "1008003866",
|
||||
40: "1008003999",
|
||||
41: "1008004011",
|
||||
42: "1008004144",
|
||||
43: "1008004277",
|
||||
44: "1008004300",
|
||||
45: "1008004433",
|
||||
46: "1008004566",
|
||||
47: "1008004699",
|
||||
48: "1008004722",
|
||||
49: "1008004855",
|
||||
50: "1008004988",
|
||||
51: "1008005000",
|
||||
52: "1008005133",
|
||||
53: "1008005266",
|
||||
54: "1008005399",
|
||||
55: "1008005422",
|
||||
56: "1008005555",
|
||||
57: "1008005688",
|
||||
58: "1008005711",
|
||||
59: "1008005844",
|
||||
60: "1008005977",
|
||||
61: "1008006099",
|
||||
62: "1008006122",
|
||||
63: "1008006255",
|
||||
64: "1008006388",
|
||||
65: "1008006411",
|
||||
66: "1008006544",
|
||||
67: "1008006677",
|
||||
68: "1008006700",
|
||||
69: "1008006833",
|
||||
70: "1008006966",
|
||||
71: "1008007088",
|
||||
72: "1008007111",
|
||||
73: "1008007244",
|
||||
74: "1008007377",
|
||||
75: "1008007400",
|
||||
76: "1008007533",
|
||||
77: "1008007666",
|
||||
78: "1008007799",
|
||||
79: "1008007822",
|
||||
80: "1008007955",
|
||||
81: "1008008077",
|
||||
82: "1008008100",
|
||||
83: "1008008233",
|
||||
84: "1008008366",
|
||||
85: "1008008499",
|
||||
86: "1008008522",
|
||||
87: "1008008655",
|
||||
88: "1008008788",
|
||||
89: "1008008811",
|
||||
90: "1008008944",
|
||||
91: "1008009066",
|
||||
92: "1008009199",
|
||||
93: "1008009222",
|
||||
94: "1008009355",
|
||||
95: "1008009488",
|
||||
96: "1008009511",
|
||||
97: "1008009644",
|
||||
98: "1008009777",
|
||||
99: "1008009800",
|
||||
100: "1008009933",
|
||||
101: "1008010022",
|
||||
102: "1008010155",
|
||||
103: "1008010288",
|
||||
104: "1008010311",
|
||||
105: "1008010444",
|
||||
106: "1008010577",
|
||||
107: "1008010600",
|
||||
108: "1008010733",
|
||||
109: "1008010866",
|
||||
110: "1008010999",
|
||||
111: "1008011011",
|
||||
112: "1008011144",
|
||||
113: "1008011277",
|
||||
114: "1008011300",
|
||||
115: "1008011433",
|
||||
116: "1008011566",
|
||||
117: "1008011699",
|
||||
118: "1008011722",
|
||||
119: "1008011855",
|
||||
120: "1008011988",
|
||||
121: "1008012000",
|
||||
122: "1008012133",
|
||||
123: "1008012266",
|
||||
124: "1008012399",
|
||||
125: "1008012422",
|
||||
126: "1008012555",
|
||||
127: "1008012688",
|
||||
128: "1008012711",
|
||||
129: "1008012844",
|
||||
130: "1008012977",
|
||||
131: "1008013099",
|
||||
132: "1008013122",
|
||||
133: "1008013255",
|
||||
134: "1008013388",
|
||||
135: "1008013411",
|
||||
136: "1008013544",
|
||||
137: "1008013677",
|
||||
138: "1008013700",
|
||||
139: "1008013833",
|
||||
140: "1008013966",
|
||||
141: "1008014088",
|
||||
142: "1008014111",
|
||||
143: "1008014244",
|
||||
144: "1008014377",
|
||||
145: "1008014400",
|
||||
146: "1008014533",
|
||||
147: "1008014666",
|
||||
148: "1008014799",
|
||||
149: "1008014822",
|
||||
150: "1008014955",
|
||||
151: "1008015077",
|
||||
152: "1008015100",
|
||||
153: "1008015233",
|
||||
154: "1008015366",
|
||||
155: "1008015499",
|
||||
156: "1008015522",
|
||||
157: "1008015655",
|
||||
158: "1008015788",
|
||||
159: "1008015811",
|
||||
160: "1008015944",
|
||||
161: "1008016066",
|
||||
162: "1008016199",
|
||||
163: "1008016222",
|
||||
164: "1008016355",
|
||||
165: "1008016488",
|
||||
166: "1008016511",
|
||||
167: "1008016644",
|
||||
168: "1008016777",
|
||||
169: "1008016800",
|
||||
170: "1008016933",
|
||||
171: "1008017055",
|
||||
172: "1008017188",
|
||||
173: "1008017211",
|
||||
174: "1008017344",
|
||||
175: "1008017477",
|
||||
176: "1008017500",
|
||||
177: "1008017633",
|
||||
178: "1008017766",
|
||||
179: "1008017899",
|
||||
180: "1008017922",
|
||||
}
|
||||
410
src/core/models.py
Normal file
@@ -0,0 +1,410 @@
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import regex
|
||||
|
||||
from src.logic.openai import name_tester, run_shortener, semester_converter
|
||||
from src.logic.semester import Semester
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prof:
|
||||
id: Optional[int] = None
|
||||
_title: Optional[str] = None
|
||||
firstname: Optional[str] = None
|
||||
lastname: Optional[str] = None
|
||||
fullname: Optional[str] = None
|
||||
mail: Optional[str] = None
|
||||
telnr: Optional[str] = None
|
||||
|
||||
# add function that sets the data based on a dict
|
||||
def from_dict(self, data: dict[str, Union[str, int]]):
|
||||
for key, value in data.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
return self
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
if self._title is None or self._title == "None":
|
||||
return ""
|
||||
return self._title
|
||||
|
||||
@title.setter
|
||||
def title(self, value: str):
|
||||
self._title = value
|
||||
|
||||
# add function that sets the data from a tuple
|
||||
def from_tuple(self, data: tuple[Union[str, int], ...]) -> "Prof":
|
||||
setattr(self, "id", data[0])
|
||||
setattr(self, "_title", data[1])
|
||||
setattr(self, "firstname", data[2])
|
||||
setattr(self, "lastname", data[3])
|
||||
setattr(self, "fullname", data[4])
|
||||
setattr(self, "mail", data[5])
|
||||
setattr(self, "telnr", data[6])
|
||||
return self
|
||||
|
||||
def name(self, comma: bool = False) -> Optional[str]:
|
||||
if self.firstname is None and self.lastname is None:
|
||||
if "," in self.fullname:
|
||||
self.firstname = self.fullname.split(",")[1].strip()
|
||||
self.lastname = self.fullname.split(",")[0].strip()
|
||||
else:
|
||||
return self.fullname
|
||||
|
||||
if comma:
|
||||
return f"{self.lastname}, {self.firstname}"
|
||||
return f"{self.lastname} {self.firstname}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookData:
|
||||
ppn: str | None = None
|
||||
title: str | None = None
|
||||
signature: str | None = None
|
||||
edition: str | None = None
|
||||
link: str | None = None
|
||||
isbn: Union[str, list[str], None] = field(default_factory=list)
|
||||
author: str | None = None
|
||||
language: Union[str, list[str], None] = field(default_factory=list)
|
||||
publisher: str | None = None
|
||||
place: str | None = None
|
||||
year: int | None = None
|
||||
pages: str | None = None
|
||||
library_location: str | None = None
|
||||
in_apparat: bool | None = False
|
||||
adis_idn: str | None = None
|
||||
old_book: Any | None = None
|
||||
media_type: str | None = None #
|
||||
in_library: bool | None = None # whether the book is in the library or not
|
||||
medianr: int | None = None # Media number in the library system
|
||||
|
||||
def __post_init__(self):
|
||||
self.library_location = (
|
||||
str(self.library_location) if self.library_location else None
|
||||
)
|
||||
if isinstance(self.language, list) and self.language:
|
||||
self.language = [lang.strip() for lang in self.language if lang.strip()]
|
||||
self.language = ",".join(self.language)
|
||||
self.year = regex.sub(r"[^\d]", "", str(self.year)) if self.year else None
|
||||
self.in_library = True if self.signature else False
|
||||
|
||||
def from_dict(self, data: dict) -> "BookData":
|
||||
for key, value in data.items():
|
||||
setattr(self, key, value)
|
||||
return self
|
||||
|
||||
def merge(self, other: "BookData") -> "BookData":
|
||||
for key, value in other.__dict__.items():
|
||||
# merge lists, if the attribute is a list, extend it
|
||||
if isinstance(value, list):
|
||||
current_value = getattr(self, key)
|
||||
if current_value is None:
|
||||
current_value = []
|
||||
elif not isinstance(current_value, list):
|
||||
current_value = [current_value]
|
||||
# extend the list with the new values, but only if they are not already in the list
|
||||
for v in value:
|
||||
if v not in current_value:
|
||||
current_value.append(v)
|
||||
setattr(self, key, current_value)
|
||||
if value is not None and (
|
||||
getattr(self, key) is None or getattr(self, key) == ""
|
||||
):
|
||||
setattr(self, key, value)
|
||||
# in language, drop all entries that are longer than 3 characters
|
||||
if isinstance(self.language, list):
|
||||
self.language = [lang for lang in self.language if len(lang) <= 4]
|
||||
return self
|
||||
|
||||
@property
|
||||
def to_dict(self) -> str:
|
||||
"""Convert the dataclass to a dictionary."""
|
||||
data_dict = {
|
||||
key: value for key, value in self.__dict__.items() if value is not None
|
||||
}
|
||||
# remove old_book from data_dict
|
||||
if "old_book" in data_dict:
|
||||
del data_dict["old_book"]
|
||||
return json.dumps(data_dict, ensure_ascii=False)
|
||||
|
||||
def from_dataclass(self, dataclass: Optional[Any]) -> None:
|
||||
if dataclass is None:
|
||||
return
|
||||
for key, value in dataclass.__dict__.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def get_book_type(self) -> str:
|
||||
if "Online" in self.pages:
|
||||
return "eBook"
|
||||
else:
|
||||
return "Druckausgabe"
|
||||
|
||||
def from_string(self, data: str) -> "BookData":
|
||||
ndata = json.loads(data)
|
||||
|
||||
return BookData(**ndata)
|
||||
|
||||
def from_LehmannsSearchResult(self, result: Any) -> "BookData":
|
||||
self.title = result.title
|
||||
self.author = "; ".join(result.authors) if result.authors else None
|
||||
self.edition = str(result.edition) if result.edition else None
|
||||
self.link = result.url
|
||||
self.isbn = (
|
||||
result.isbn13
|
||||
if isinstance(result.isbn13, list)
|
||||
else [result.isbn13]
|
||||
if result.isbn13
|
||||
else []
|
||||
)
|
||||
self.pages = str(result.pages) if result.pages else None
|
||||
self.publisher = result.publisher
|
||||
self.year = str(result.year) if result.year else None
|
||||
# self.pages = str(result.pages) if result.pages else None
|
||||
return self
|
||||
|
||||
@property
|
||||
def edition_number(self) -> Optional[int]:
|
||||
if self.edition is None:
|
||||
return 0
|
||||
match = regex.search(r"(\d+)", self.edition)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailData:
|
||||
subject: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
mailto: Optional[str] = None
|
||||
prof: Optional[str] = None
|
||||
|
||||
|
||||
class Subjects(Enum):
|
||||
BIOLOGY = (1, "Biologie")
|
||||
CHEMISTRY = (2, "Chemie")
|
||||
GERMAN = (3, "Deutsch")
|
||||
ENGLISH = (4, "Englisch")
|
||||
PEDAGOGY = (5, "Erziehungswissenschaft")
|
||||
FRENCH = (6, "Französisch")
|
||||
GEOGRAPHY = (7, "Geographie")
|
||||
HISTORY = (8, "Geschichte")
|
||||
HEALTH_EDUCATION = (9, "Gesundheitspädagogik")
|
||||
HTW = (10, "Haushalt / Textil")
|
||||
ART = (11, "Kunst")
|
||||
MATH_IT = (12, "Mathematik / Informatik")
|
||||
MEDIAPEDAGOGY = (13, "Medien in der Bildung")
|
||||
MUSIC = (14, "Musik")
|
||||
PHILOSOPHY = (15, "Philosophie")
|
||||
PHYSICS = (16, "Physik")
|
||||
POLITICS = (17, "Politikwissenschaft")
|
||||
PRORECTORATE = (18, "Prorektorat Lehre und Studium")
|
||||
PSYCHOLOGY = (19, "Psychologie")
|
||||
SOCIOLOGY = (20, "Soziologie")
|
||||
SPORT = (21, "Sport")
|
||||
TECHNIC = (22, "Technik")
|
||||
THEOLOGY = (23, "Theologie")
|
||||
ECONOMICS = (24, "Wirtschaftslehre")
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
return self.value[0]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.value[1]
|
||||
|
||||
@classmethod
|
||||
def get_index(cls, name: str) -> Optional[int]:
|
||||
for i in cls:
|
||||
if i.name == name:
|
||||
return i.id - 1
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Apparat:
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
prof_id: int | None = None
|
||||
subject: str | None = None
|
||||
appnr: int | None = None
|
||||
created_semester: str | None = None
|
||||
extended_at: str | None = None
|
||||
eternal: bool = False
|
||||
extend_until: str | None = None
|
||||
deleted: int | None = None
|
||||
deleted_date: str | None = None
|
||||
apparat_id_adis: str | None = None
|
||||
prof_id_adis: str | None = None
|
||||
konto: int | None = None
|
||||
|
||||
def from_tuple(self, data: tuple[Any, ...]) -> "Apparat":
|
||||
self.id = data[0]
|
||||
self.name = data[1]
|
||||
self.prof_id = data[2]
|
||||
self.subject = data[3]
|
||||
self.appnr = data[4]
|
||||
self.created_semester = data[5]
|
||||
self.extended_at = data[6]
|
||||
self.eternal = data[7]
|
||||
self.extend_until = data[8]
|
||||
self.deleted = data[9]
|
||||
self.deleted_date = data[10]
|
||||
self.apparat_id_adis = data[11]
|
||||
self.prof_id_adis = data[12]
|
||||
self.konto = data[13]
|
||||
return self
|
||||
|
||||
@property
|
||||
def get_semester(self) -> Optional[str]:
|
||||
if self.extend_until is not None:
|
||||
return self.extend_until
|
||||
else:
|
||||
return self.created_semester
|
||||
|
||||
|
||||
@dataclass
|
||||
class ELSA:
|
||||
id: int | None = None
|
||||
date: str | None = None
|
||||
semester: str | None = None
|
||||
prof_id: int | None = None
|
||||
|
||||
def from_tuple(self, data: tuple[Any, ...]) -> "ELSA":
|
||||
self.id = data[0]
|
||||
self.date = data[1]
|
||||
self.semester = data[2]
|
||||
self.prof_id = data[3]
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApparatData:
|
||||
prof: Prof = field(default_factory=Prof)
|
||||
apparat: Apparat = field(default_factory=Apparat)
|
||||
|
||||
|
||||
@dataclass
|
||||
class XMLMailSubmission:
|
||||
name: Optional[str] = None
|
||||
lastname: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
telno: Optional[int] = None
|
||||
email: Optional[str] = None
|
||||
app_name: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
semester: Optional[Semester] = None
|
||||
books: Optional[list[BookData]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Book:
|
||||
author: str = None
|
||||
year: str = None
|
||||
edition: str = None
|
||||
title: str = None
|
||||
location: str = None
|
||||
publisher: str = None
|
||||
signature: str = None
|
||||
internal_notes: str = None
|
||||
|
||||
@property
|
||||
def has_signature(self) -> bool:
|
||||
return self.signature is not None and self.signature != ""
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return all(
|
||||
[
|
||||
self.author == "",
|
||||
self.year == "",
|
||||
self.edition == "",
|
||||
self.title == "",
|
||||
self.location == "",
|
||||
self.publisher == "",
|
||||
self.signature == "",
|
||||
self.internal_notes == "",
|
||||
]
|
||||
)
|
||||
|
||||
def from_dict(self, data: dict[str, Any]):
|
||||
for key, value in data.items():
|
||||
value = value.strip()
|
||||
if value == "\u2002\u2002\u2002\u2002\u2002":
|
||||
value = ""
|
||||
|
||||
if key == "Autorenname(n):Nachname, Vorname":
|
||||
self.author = value
|
||||
elif key == "Jahr/Auflage":
|
||||
self.year = value.split("/")[0] if "/" in value else value
|
||||
self.edition = value.split("/")[1] if "/" in value else ""
|
||||
elif key == "Titel":
|
||||
self.title = value
|
||||
elif key == "Ort und Verlag":
|
||||
self.location = value.split(",")[0] if "," in value else value
|
||||
self.publisher = value.split(",")[1] if "," in value else ""
|
||||
elif key == "Standnummer":
|
||||
self.signature = value.strip()
|
||||
elif key == "Interne Vermerke":
|
||||
self.internal_notes = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class SemapDocument:
|
||||
subject: str = None
|
||||
phoneNumber: int = None
|
||||
mail: str = None
|
||||
title: str = None
|
||||
title_suggestions: list[str] = None
|
||||
semester: Union[str, Semester] = None
|
||||
books: list[Book] = None
|
||||
eternal: bool = False
|
||||
personName: str = None
|
||||
personTitle: str = None
|
||||
title_length = 0
|
||||
title_max_length = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.title_suggestions = []
|
||||
|
||||
@property
|
||||
def nameSetter(self):
|
||||
data = name_tester(self.personTitle)
|
||||
name = f"{data['last_name']}, {data['first_name']}"
|
||||
if data["title"] is not None:
|
||||
title = data["title"]
|
||||
self.personTitle = title
|
||||
self.personName = name
|
||||
self.title_length = len(self.title) + 3 + len(self.personName.split(",")[0])
|
||||
if self.title_length > 40:
|
||||
name_len = len(self.personName.split(",")[0])
|
||||
self.title_max_length = 38 - name_len
|
||||
suggestions = run_shortener(self.title, self.title_max_length)
|
||||
for suggestion in suggestions:
|
||||
self.title_suggestions.append(suggestion["shortened_string"])
|
||||
else:
|
||||
self.title_suggestions = []
|
||||
pass
|
||||
|
||||
@property
|
||||
def renameSemester(self) -> None:
|
||||
if self.semester:
|
||||
if ", Dauer" in self.semester:
|
||||
self.semester = self.semester.split(",")[0]
|
||||
self.eternal = True
|
||||
self.semester = Semester().from_string(self.semester)
|
||||
else:
|
||||
self.semester = Semester().from_string(
|
||||
semester_converter(self.semester)
|
||||
)
|
||||
|
||||
@property
|
||||
def signatures(self) -> list[str]:
|
||||
if self.books is not None:
|
||||
return [book.signature for book in self.books if book.has_signature]
|
||||
return []
|
||||
248
src/core/semester.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Semester helper class
|
||||
|
||||
A small utility around the *German* academic calendar that distinguishes
|
||||
between *Wintersemester* (WiSe) and *Sommersemester* (SoSe).
|
||||
|
||||
Key points
|
||||
----------
|
||||
* A **`Semester`** is identified by a *term* ("SoSe" or "WiSe") and the last two
|
||||
digits of the calendar year in which the term *starts*.
|
||||
* Formatting **never** pads the year with a leading zero – so ``6`` stays ``6``.
|
||||
* ``offset(n)`` and the static ``generate_missing`` reliably walk the timeline
|
||||
one semester at a time with correct year transitions:
|
||||
|
||||
SoSe 6 → **WiSe 6/7** → SoSe 7 → WiSe 7/8 → …
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from src.shared.logging import log
|
||||
|
||||
|
||||
class Semester:
|
||||
"""Represents a German university semester (WiSe or SoSe)."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Class‑level defaults – will be *copied* to each instance and then
|
||||
# potentially overwritten in ``__init__``.
|
||||
# ------------------------------------------------------------------
|
||||
_year: int | None = int(str(datetime.datetime.now().year)[2:]) # 24 → 24
|
||||
_semester: str | None = None # "WiSe" or "SoSe" – set later
|
||||
_month: int | None = datetime.datetime.now().month
|
||||
value: str | None = None # Human‑readable label, e.g. "WiSe 23/24"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction helpers
|
||||
# ------------------------------------------------------------------
|
||||
def __init__(
|
||||
self,
|
||||
year: int | None = None,
|
||||
semester: str | None = None,
|
||||
month: int | None = None,
|
||||
) -> None:
|
||||
if year is not None:
|
||||
self._year = int(year)
|
||||
if semester is not None:
|
||||
if semester not in ("WiSe", "SoSe"):
|
||||
raise ValueError("semester must be 'WiSe' or 'SoSe'")
|
||||
self._semester = semester
|
||||
if month is not None:
|
||||
self._month = month
|
||||
|
||||
self.__post_init__()
|
||||
|
||||
def __post_init__(self) -> None: # noqa: D401 – keep original name
|
||||
if self._year is None:
|
||||
self._year = int(str(datetime.datetime.now().year)[2:])
|
||||
if self._month is None:
|
||||
self._month = datetime.datetime.now().month
|
||||
if self._semester is None:
|
||||
self._generate_semester_from_month()
|
||||
self._compute_value()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dunder helpers
|
||||
# ------------------------------------------------------------------
|
||||
def __str__(self) -> str: # noqa: D401 – keep original name
|
||||
return self.value or "<invalid Semester>"
|
||||
|
||||
def __repr__(self) -> str: # Helpful for debugging lists
|
||||
return f"Semester({self._year!r}, {self._semester!r})"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _generate_semester_from_month(self) -> None:
|
||||
"""Infer *WiSe* / *SoSe* from the month attribute."""
|
||||
self._semester = "WiSe" if (self._month <= 3 or self._month > 9) else "SoSe"
|
||||
|
||||
def _compute_value(self) -> None:
|
||||
"""Human‑readable semester label – e.g. ``WiSe 23/24`` or ``SoSe 24``."""
|
||||
year = self._year
|
||||
if self._semester == "WiSe":
|
||||
next_year = (year + 1) % 100 # wrap 99 → 0
|
||||
self.value = f"WiSe {year}/{next_year}"
|
||||
else: # SoSe
|
||||
self.value = f"SoSe {year}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
def offset(self, value: int) -> "Semester":
|
||||
"""Return a new :class:`Semester` *value* steps away.
|
||||
|
||||
The algorithm maps every semester to a monotonically increasing
|
||||
*linear index* so that simple addition suffices:
|
||||
|
||||
``index = year * 2 + (0 if SoSe else 1)``.
|
||||
"""
|
||||
if not isinstance(value, int):
|
||||
raise TypeError("value must be an int (number of semesters to jump)")
|
||||
if value == 0:
|
||||
return Semester(self._year, self._semester)
|
||||
|
||||
current_idx = self._year * 2 + (0 if self._semester == "SoSe" else 1)
|
||||
target_idx = current_idx + value
|
||||
if target_idx < 0:
|
||||
raise ValueError("offset would result in a negative year – not supported")
|
||||
|
||||
new_year, semester_bit = divmod(target_idx, 2)
|
||||
new_semester = "SoSe" if semester_bit == 0 else "WiSe"
|
||||
return Semester(new_year, new_semester)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Comparison helpers
|
||||
# ------------------------------------------------------------------
|
||||
def isPastSemester(self, current: "Semester") -> bool:
|
||||
log.debug(f"Comparing {self} < {current}")
|
||||
if self.year < current.year:
|
||||
return True
|
||||
if self.year == current.year:
|
||||
return (
|
||||
self.semester == "WiSe" and current.semester == "SoSe"
|
||||
) # WiSe before next SoSe
|
||||
return False
|
||||
|
||||
def isFutureSemester(self, current: "Semester") -> bool:
|
||||
if self.year > current.year:
|
||||
return True
|
||||
if self.year == current.year:
|
||||
return (
|
||||
self.semester == "SoSe" and current.semester == "WiSe"
|
||||
) # SoSe after WiSe of same year
|
||||
return False
|
||||
|
||||
def isMatch(self, other: "Semester") -> bool:
|
||||
return self.year == other.year and self.semester == other.semester
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convenience properties
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def next(self) -> "Semester":
|
||||
return self.offset(1)
|
||||
|
||||
@property
|
||||
def previous(self) -> "Semester":
|
||||
return self.offset(-1)
|
||||
|
||||
@property
|
||||
def year(self) -> int:
|
||||
return self._year
|
||||
|
||||
@property
|
||||
def semester(self) -> str:
|
||||
return self._semester
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Static helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def generate_missing(start: "Semester", end: "Semester") -> list[str]:
|
||||
"""Return all consecutive semesters from *start* to *end* (inclusive)."""
|
||||
if not isinstance(start, Semester) or not isinstance(end, Semester):
|
||||
raise TypeError("start and end must be Semester instances")
|
||||
if start.isFutureSemester(end) and not start.isMatch(end):
|
||||
raise ValueError("'start' must not be after 'end'")
|
||||
|
||||
chain: list[Semester] = [start.value]
|
||||
current = start
|
||||
while not current.isMatch(end):
|
||||
current = current.next
|
||||
chain.append(current.value)
|
||||
if len(chain) > 1000: # sanity guard
|
||||
raise RuntimeError("generate_missing exceeded sane iteration limit")
|
||||
return chain
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parsing helper
|
||||
# ------------------------------------------------------------------
|
||||
@classmethod
|
||||
def from_string(cls, s: str) -> "Semester":
|
||||
"""Parse a human‑readable semester label and return a :class:`Semester`.
|
||||
|
||||
Accepted formats (case‑insensitive)::
|
||||
|
||||
"SoSe <YY>" → SoSe of year YY
|
||||
"WiSe <YY>/<YY+1>" → Winter term starting in YY
|
||||
"WiSe <YY>" → Shorthand for the above (next year implied)
|
||||
|
||||
``YY`` may contain a leading zero ("06" → 6).
|
||||
"""
|
||||
if not isinstance(s, str):
|
||||
raise TypeError("s must be a string")
|
||||
|
||||
pattern = r"\s*(WiSe|SoSe)\s+(\d{1,2})(?:\s*/\s*(\d{1,2}))?\s*"
|
||||
m = re.fullmatch(pattern, s, flags=re.IGNORECASE)
|
||||
if not m:
|
||||
raise ValueError(
|
||||
"invalid semester string format – expected 'SoSe YY' or 'WiSe YY/YY' (spacing flexible)"
|
||||
)
|
||||
|
||||
term_raw, y1_str, y2_str = m.groups()
|
||||
term = term_raw.capitalize() # normalize case → "WiSe" or "SoSe"
|
||||
year = int(y1_str.lstrip("0") or "0") # "06" → 6, "0" stays 0
|
||||
|
||||
if term == "SoSe":
|
||||
if y2_str is not None:
|
||||
raise ValueError(
|
||||
"SoSe string should not contain '/' followed by a second year"
|
||||
)
|
||||
return cls(year, "SoSe")
|
||||
|
||||
# term == "WiSe"
|
||||
if y2_str is not None:
|
||||
next_year = int(y2_str.lstrip("0") or "0")
|
||||
expected_next = (year + 1) % 100
|
||||
if next_year != expected_next:
|
||||
raise ValueError("WiSe second year must equal first year + 1 (mod 100)")
|
||||
# Accept both explicit "WiSe 6/7" and shorthand "WiSe 6"
|
||||
return cls(year, "WiSe")
|
||||
|
||||
|
||||
# ------------------------- quick self‑test -------------------------
|
||||
if __name__ == "__main__":
|
||||
# Chain generation demo ------------------------------------------------
|
||||
s_start = Semester(6, "SoSe") # SoSe 6
|
||||
s_end = Semester(25, "WiSe") # WiSe 25/26
|
||||
chain = Semester.generate_missing(s_start, s_end)
|
||||
# print("generate_missing:", [str(s) for s in chain])
|
||||
|
||||
# Parsing demo ---------------------------------------------------------
|
||||
examples = [
|
||||
"SoSe 6",
|
||||
"WiSe 6/7",
|
||||
"WiSe 6",
|
||||
"SoSe 23",
|
||||
"WiSe 23/24",
|
||||
"WiSe 24",
|
||||
"WiSe 99/00",
|
||||
"SoSe 00",
|
||||
"WiSe 100/101", # test large year
|
||||
]
|
||||
for ex in examples:
|
||||
parsed = Semester.from_string(ex)
|
||||
print(f"'{ex}' → {parsed} ({parsed.year=}, {parsed.semester=})")
|
||||
5
src/database/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Database layer for data persistence."""
|
||||
|
||||
from .connection import Database
|
||||
|
||||
__all__ = ["Database"]
|
||||
2008
src/database/connection.py
Normal file
132
src/database/migrations/V001__create_base_tables.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semesterapparat (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT,
|
||||
prof_id INTEGER,
|
||||
fach TEXT,
|
||||
appnr INTEGER,
|
||||
erstellsemester TEXT,
|
||||
verlängert_am TEXT,
|
||||
dauer BOOLEAN,
|
||||
verlängerung_bis TEXT,
|
||||
deletion_status INTEGER,
|
||||
deleted_date TEXT,
|
||||
apparat_id_adis INTEGER,
|
||||
prof_id_adis INTEGER,
|
||||
konto INTEGER,
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
bookdata TEXT,
|
||||
app_id INTEGER,
|
||||
prof_id INTEGER,
|
||||
deleted INTEGER DEFAULT (0),
|
||||
available BOOLEAN,
|
||||
reservation BOOLEAN,
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id),
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filename TEXT,
|
||||
fileblob BLOB,
|
||||
app_id INTEGER,
|
||||
filetyp TEXT,
|
||||
prof_id INTEGER REFERENCES prof (id),
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
created_at date NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
message TEXT NOT NULL,
|
||||
remind_at date NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id INTEGER NOT NULL,
|
||||
appnr INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES user (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prof (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
titel TEXT,
|
||||
fname TEXT,
|
||||
lname TEXT,
|
||||
fullname TEXT NOT NULL UNIQUE,
|
||||
mail TEXT,
|
||||
telnr TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subjects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elsa (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
semester TEXT NOT NULL,
|
||||
prof_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elsa_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
fileblob BLOB NOT NULL,
|
||||
elsa_id INTEGER NOT NULL,
|
||||
filetyp TEXT NOT NULL,
|
||||
FOREIGN KEY (elsa_id) REFERENCES elsa (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elsa_media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
work_author TEXT,
|
||||
section_author TEXT,
|
||||
year TEXT,
|
||||
edition TEXT,
|
||||
work_title TEXT,
|
||||
chapter_title TEXT,
|
||||
location TEXT,
|
||||
publisher TEXT,
|
||||
signature TEXT,
|
||||
issue TEXT,
|
||||
pages TEXT,
|
||||
isbn TEXT,
|
||||
type TEXT,
|
||||
elsa_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (elsa_id) REFERENCES elsa (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS neweditions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
new_bookdata TEXT,
|
||||
old_edition_id INTEGER,
|
||||
for_apparat INTEGER,
|
||||
ordered BOOLEAN DEFAULT (0),
|
||||
FOREIGN KEY (old_edition_id) REFERENCES media (id),
|
||||
FOREIGN KEY (for_apparat) REFERENCES semesterapparat (id)
|
||||
);
|
||||
|
||||
-- Helpful indices to speed up frequent lookups and joins
|
||||
CREATE INDEX IF NOT EXISTS idx_media_app_prof ON media(app_id, prof_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_deleted ON media(deleted);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_available ON media(available);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_remind_at ON messages(remind_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_semesterapparat_prof ON semesterapparat(prof_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_semesterapparat_appnr ON semesterapparat(appnr);
|
||||
|
||||
COMMIT;
|
||||
10
src/database/migrations/V002__create_table_webadis_login.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webadis_login (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE webadis_login
|
||||
ADD COLUMN effective_range TEXT;
|
||||
|
||||
COMMIT;
|
||||
112
src/database/schemas.py
Normal file
@@ -0,0 +1,112 @@
|
||||
CREATE_TABLE_APPARAT = """CREATE TABLE semesterapparat (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT,
|
||||
prof_id INTEGER,
|
||||
fach TEXT,
|
||||
appnr INTEGER,
|
||||
erstellsemester TEXT,
|
||||
verlängert_am TEXT,
|
||||
dauer BOOLEAN,
|
||||
verlängerung_bis TEXT,
|
||||
deletion_status INTEGER,
|
||||
deleted_date TEXT,
|
||||
apparat_id_adis INTEGER,
|
||||
prof_id_adis INTEGER,
|
||||
konto INTEGER,
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id)
|
||||
)"""
|
||||
CREATE_TABLE_MEDIA = """CREATE TABLE media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
bookdata TEXT,
|
||||
app_id INTEGER,
|
||||
prof_id INTEGER,
|
||||
deleted INTEGER DEFAULT (0),
|
||||
available BOOLEAN,
|
||||
reservation BOOLEAN,
|
||||
FOREIGN KEY (prof_id) REFERENCES prof (id),
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
)"""
|
||||
|
||||
CREATE_TABLE_FILES = """CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filename TEXT,
|
||||
fileblob BLOB,
|
||||
app_id INTEGER,
|
||||
filetyp TEXT,
|
||||
prof_id INTEGER REFERENCES prof (id),
|
||||
FOREIGN KEY (app_id) REFERENCES semesterapparat (id)
|
||||
)"""
|
||||
CREATE_TABLE_MESSAGES = """CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
created_at date NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
message TEXT NOT NULL,
|
||||
remind_at date NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id INTEGER NOT NULL,
|
||||
appnr INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES user (id)
|
||||
)"""
|
||||
CREATE_TABLE_PROF = """CREATE TABLE prof (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
titel TEXT,
|
||||
fname TEXT,
|
||||
lname TEXT,
|
||||
fullname TEXT NOT NULL UNIQUE,
|
||||
mail TEXT,
|
||||
telnr TEXT
|
||||
)"""
|
||||
CREATE_TABLE_USER = """CREATE TABLE user (
|
||||
id integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
name TEXT
|
||||
)"""
|
||||
CREATE_TABLE_SUBJECTS = """CREATE TABLE subjects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
)"""
|
||||
|
||||
CREATE_ELSA_TABLE = """CREATE TABLE elsa (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
semester TEXT NOT NULL,
|
||||
prof_id INTEGER NOT NULL
|
||||
)"""
|
||||
CREATE_ELSA_FILES_TABLE = """CREATE TABLE elsa_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
fileblob BLOB NOT NULL,
|
||||
elsa_id INTEGER NOT NULL,
|
||||
filetyp TEXT NOT NULL,
|
||||
FOREIGN KEY (elsa_id) REFERENCES elsa (id)
|
||||
)"""
|
||||
CREATE_ELSA_MEDIA_TABLE = """CREATE TABLE elsa_media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
work_author TEXT,
|
||||
section_author TEXT,
|
||||
year TEXT,
|
||||
edition TEXT,
|
||||
work_title TEXT,
|
||||
chapter_title TEXT,
|
||||
location TEXT,
|
||||
publisher TEXT,
|
||||
signature TEXT,
|
||||
issue TEXT,
|
||||
pages TEXT,
|
||||
isbn TEXT,
|
||||
type TEXT,
|
||||
elsa_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (elsa_id) REFERENCES elsa (id)
|
||||
)"""
|
||||
CREATE_TABLE_NEWEDITIONS = """CREATE TABLE neweditions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
new_bookdata TEXT,
|
||||
old_edition_id INTEGER,
|
||||
for_apparat INTEGER,
|
||||
ordered BOOLEAN DEFAULT (0),
|
||||
FOREIGN KEY (old_edition_id) REFERENCES media (id),
|
||||
FOREIGN KEY (for_apparat) REFERENCES semesterapparat (id)
|
||||
)"""
|
||||
@@ -1 +1,2 @@
|
||||
|
||||
|
||||
371
src/documents/generators.py
Normal file
@@ -0,0 +1,371 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from os.path import basename
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
|
||||
from src import settings
|
||||
from src.shared.logging import log
|
||||
|
||||
logger = log
|
||||
|
||||
font = "Cascadia Mono"
|
||||
|
||||
|
||||
def print_document(file: str) -> None:
|
||||
# send document to printer as attachment of email
|
||||
import smtplib
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
smtp = settings.mail.smtp_server
|
||||
port = settings.mail.port
|
||||
sender_email = settings.mail.sender
|
||||
password = settings.mail.password
|
||||
receiver = settings.mail.printer_mail
|
||||
message = MIMEMultipart()
|
||||
message["From"] = sender_email
|
||||
message["To"] = receiver
|
||||
message["cc"] = settings.mail.sender
|
||||
message["Subject"] = "."
|
||||
mail_body = "."
|
||||
message.attach(MIMEText(mail_body, "html"))
|
||||
with open(file, "rb") as fil:
|
||||
part = MIMEApplication(fil.read(), Name=basename(file))
|
||||
# After the file is closed
|
||||
part["Content-Disposition"] = 'attachment; filename="%s"' % basename(file)
|
||||
message.attach(part)
|
||||
mail = message.as_string()
|
||||
with smtplib.SMTP_SSL(smtp, port) as server:
|
||||
server.connect(smtp, port)
|
||||
server.login(settings.mail.user_name, password)
|
||||
server.sendmail(sender_email, receiver, mail)
|
||||
server.quit()
|
||||
log.success("Mail sent")
|
||||
|
||||
|
||||
class SemesterError(Exception):
|
||||
"""Custom exception for semester-related errors."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message)
|
||||
log.error(message)
|
||||
|
||||
def __str__(self):
|
||||
return f"SemesterError: {self.args[0]}"
|
||||
|
||||
|
||||
class SemesterDocument:
|
||||
def __init__(
|
||||
self,
|
||||
apparats: list[tuple[int, str]],
|
||||
semester: str,
|
||||
filename: str,
|
||||
full: bool = False,
|
||||
):
|
||||
assert isinstance(apparats, list), SemesterError(
|
||||
"Apparats must be a list of tuples"
|
||||
)
|
||||
assert all(isinstance(apparat, tuple) for apparat in apparats), SemesterError(
|
||||
"Apparats must be a list of tuples"
|
||||
)
|
||||
assert all(isinstance(apparat[0], int) for apparat in apparats), SemesterError(
|
||||
"Apparat numbers must be integers"
|
||||
)
|
||||
assert all(isinstance(apparat[1], str) for apparat in apparats), SemesterError(
|
||||
"Apparat names must be strings"
|
||||
)
|
||||
assert isinstance(semester, str), SemesterError("Semester must be a string")
|
||||
assert "." not in filename and isinstance(filename, str), SemesterError(
|
||||
"Filename must be a string and not contain an extension"
|
||||
)
|
||||
self.doc = Document()
|
||||
self.apparats = apparats
|
||||
self.semester = semester
|
||||
self.table_font_normal = font
|
||||
self.table_font_bold = font
|
||||
self.header_font = font
|
||||
self.header_font_size = Pt(26)
|
||||
self.sub_header_font_size = Pt(18)
|
||||
self.table_font_size = Pt(10)
|
||||
self.color_red = RGBColor(255, 0, 0)
|
||||
self.color_blue = RGBColor(0, 0, 255)
|
||||
self.filename = filename
|
||||
if full:
|
||||
log.info("Full document generation")
|
||||
self.cleanup
|
||||
log.info("Cleanup done")
|
||||
self.make_document()
|
||||
log.info("Document created")
|
||||
self.create_pdf()
|
||||
log.info("PDF created")
|
||||
print_document(self.filename + ".pdf")
|
||||
log.info("Document printed")
|
||||
|
||||
def set_table_border(self, table):
|
||||
"""
|
||||
Adds a full border to the table.
|
||||
|
||||
:param table: Table object to which the border will be applied.
|
||||
"""
|
||||
tbl = table._element
|
||||
tbl_pr = tbl.xpath("w:tblPr")[0]
|
||||
tbl_borders = OxmlElement("w:tblBorders")
|
||||
|
||||
# Define border styles
|
||||
for border_name in ["top", "left", "bottom", "right", "insideH", "insideV"]:
|
||||
border = OxmlElement(f"w:{border_name}")
|
||||
border.set(qn("w:val"), "single")
|
||||
border.set(qn("w:sz"), "4") # Thickness of the border
|
||||
border.set(qn("w:space"), "0")
|
||||
border.set(qn("w:color"), "000000") # Black color
|
||||
tbl_borders.append(border)
|
||||
|
||||
tbl_pr.append(tbl_borders)
|
||||
|
||||
def create_sorted_table(self) -> None:
|
||||
# Sort the apparats list by the string in the tuple (index 1)
|
||||
self.apparats.sort(key=lambda x: x[1])
|
||||
# Create a table with rows equal to the length of the apparats list
|
||||
table = self.doc.add_table(rows=len(self.apparats), cols=2)
|
||||
table.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
# Set column widths by directly modifying the cell properties
|
||||
widths = [Cm(1.19), Cm(10)]
|
||||
for col_idx, width in enumerate(widths):
|
||||
for cell in table.columns[col_idx].cells:
|
||||
cell_width_element = cell._element.xpath(".//w:tcPr")[0]
|
||||
tcW = OxmlElement("w:tcW")
|
||||
tcW.set(qn("w:w"), str(int(width.cm * 567))) # Convert cm to twips
|
||||
tcW.set(qn("w:type"), "dxa")
|
||||
cell_width_element.append(tcW)
|
||||
|
||||
# Adjust row heights
|
||||
for row in table.rows:
|
||||
trPr = row._tr.get_or_add_trPr() # Get or add the <w:trPr> element
|
||||
trHeight = OxmlElement("w:trHeight")
|
||||
trHeight.set(
|
||||
qn("w:val"), str(int(Pt(15).pt * 20))
|
||||
) # Convert points to twips
|
||||
trHeight.set(qn("w:hRule"), "exact") # Use "exact" for fixed height
|
||||
trPr.append(trHeight)
|
||||
|
||||
# Fill the table with sorted data
|
||||
for row_idx, (number, name) in enumerate(self.apparats):
|
||||
row = table.rows[row_idx]
|
||||
|
||||
# Set font for the first column (number)
|
||||
cell_number_paragraph = row.cells[0].paragraphs[0]
|
||||
cell_number_run = cell_number_paragraph.add_run(str(number))
|
||||
cell_number_run.font.name = self.table_font_bold
|
||||
cell_number_run.font.size = self.table_font_size
|
||||
cell_number_run.font.bold = True
|
||||
cell_number_run.font.color.rgb = self.color_red
|
||||
cell_number_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
# Set font for the second column (name)
|
||||
cell_name_paragraph = row.cells[1].paragraphs[0]
|
||||
words = name.split()
|
||||
if words:
|
||||
# Add the first word in bold
|
||||
bold_run = cell_name_paragraph.add_run(words[0])
|
||||
bold_run.font.bold = True
|
||||
bold_run.font.name = self.table_font_bold
|
||||
bold_run.font.size = self.table_font_size
|
||||
|
||||
# Add the rest of the words normally
|
||||
if len(words) > 1:
|
||||
normal_run = cell_name_paragraph.add_run(" " + " ".join(words[1:]))
|
||||
normal_run.font.name = self.table_font_normal
|
||||
normal_run.font.size = self.table_font_size
|
||||
cell_name_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
|
||||
|
||||
self.set_table_border(table)
|
||||
|
||||
def make_document(self):
|
||||
# Create a new Document
|
||||
section = self.doc.sections[0]
|
||||
section.top_margin = Cm(2.54) # Default 1 inch (can adjust as needed)
|
||||
section.bottom_margin = Cm(1.5) # Set bottom margin to 1.5 cm
|
||||
section.left_margin = Cm(2.54) # Default 1 inch
|
||||
section.right_margin = Cm(2.54) # Default 1 inch
|
||||
|
||||
# Add the current date
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
date_paragraph = self.doc.add_paragraph(current_date)
|
||||
date_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
|
||||
|
||||
# Add a header
|
||||
semester = f"Semesterapparate {self.semester}"
|
||||
header = self.doc.add_paragraph(semester)
|
||||
header.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
header_run = header.runs[0]
|
||||
header_run.font.name = self.header_font
|
||||
header_run.font.size = self.header_font_size
|
||||
header_run.font.bold = True
|
||||
header_run.font.color.rgb = self.color_blue
|
||||
|
||||
sub_header = self.doc.add_paragraph("(Alphabetisch)")
|
||||
sub_header.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
sub_header_run = sub_header.runs[0]
|
||||
sub_header_run.font.name = self.header_font
|
||||
sub_header_run.font.size = self.sub_header_font_size
|
||||
sub_header_run.font.color.rgb = self.color_red
|
||||
|
||||
self.doc.add_paragraph("")
|
||||
|
||||
self.create_sorted_table()
|
||||
|
||||
def save_document(self, name: str) -> None:
|
||||
# Save the document
|
||||
self.doc.save(name)
|
||||
|
||||
def create_pdf(self) -> None:
|
||||
# Save the document
|
||||
import comtypes.client
|
||||
|
||||
word = comtypes.client.CreateObject("Word.Application") # type: ignore
|
||||
self.save_document(self.filename + ".docx")
|
||||
docpath = os.path.abspath(self.filename + ".docx")
|
||||
doc = word.Documents.Open(docpath)
|
||||
curdir = os.getcwd()
|
||||
doc.SaveAs(f"{curdir}/{self.filename}.pdf", FileFormat=17)
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
log.debug("PDF saved")
|
||||
|
||||
@property
|
||||
def cleanup(self) -> None:
|
||||
if os.path.exists(f"{self.filename}.docx"):
|
||||
os.remove(f"{self.filename}.docx")
|
||||
os.remove(f"{self.filename}.pdf")
|
||||
|
||||
@property
|
||||
def send(self) -> None:
|
||||
print_document(self.filename + ".pdf")
|
||||
log.debug("Document sent to printer")
|
||||
|
||||
|
||||
class SemapSchilder:
|
||||
def __init__(self, entries: list[str]):
|
||||
self.entries = entries
|
||||
self.filename = "Schilder"
|
||||
self.font_size = Pt(23)
|
||||
self.font_name = font
|
||||
self.doc = Document()
|
||||
self.define_doc_properties()
|
||||
self.add_entries()
|
||||
self.cleanup()
|
||||
self.create_pdf()
|
||||
|
||||
def define_doc_properties(self):
|
||||
# set the doc to have a top margin of 1cm, left and right are 0.5cm, bottom is 0cm
|
||||
section = self.doc.sections[0]
|
||||
section.top_margin = Cm(1)
|
||||
section.bottom_margin = Cm(0)
|
||||
section.left_margin = Cm(0.5)
|
||||
section.right_margin = Cm(0.5)
|
||||
|
||||
# set the font to Times New Roman, size 23 bold, color black
|
||||
for paragraph in self.doc.paragraphs:
|
||||
for run in paragraph.runs:
|
||||
run.font.name = self.font_name
|
||||
run.font.size = self.font_size
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = RGBColor(0, 0, 0)
|
||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
# if the length of the text is
|
||||
|
||||
def add_entries(self):
|
||||
for entry in self.entries:
|
||||
paragraph = self.doc.add_paragraph(entry)
|
||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
paragraph.paragraph_format.line_spacing = Pt(23) # Set fixed line spacing
|
||||
paragraph.paragraph_format.space_before = Pt(2) # Remove spacing before
|
||||
paragraph.paragraph_format.space_after = Pt(2) # Remove spacing after
|
||||
|
||||
run = paragraph.runs[0]
|
||||
run.font.name = self.font_name
|
||||
run.font.size = self.font_size
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = RGBColor(0, 0, 0)
|
||||
|
||||
# Add a line to be used as a guideline for cutting
|
||||
line = self.doc.add_paragraph()
|
||||
line.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
line.paragraph_format.line_spacing = Pt(23) # Match line spacing
|
||||
line.paragraph_format.space_before = Pt(2) # Remove spacing before
|
||||
line.paragraph_format.space_after = Pt(2) # Remove spacing after
|
||||
line.add_run("--------------------------")
|
||||
|
||||
def save_document(self):
|
||||
# Save the document
|
||||
self.doc.save(f"{self.filename}.docx")
|
||||
log.debug(f"Document saved as {self.filename}.docx")
|
||||
|
||||
def create_pdf(self) -> None:
|
||||
# Save the document
|
||||
import comtypes.client
|
||||
|
||||
word = comtypes.client.CreateObject("Word.Application") # type: ignore
|
||||
self.save_document()
|
||||
docpath = os.path.abspath(f"{self.filename}.docx")
|
||||
doc = word.Documents.Open(docpath)
|
||||
curdir = os.getcwd()
|
||||
doc.SaveAs(f"{curdir}/{self.filename}.pdf", FileFormat=17)
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
log.debug("PDF saved")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if os.path.exists(f"{self.filename}.docx"):
|
||||
os.remove(f"{self.filename}.docx")
|
||||
if os.path.exists(f"{self.filename}.pdf"):
|
||||
os.remove(f"{self.filename}.pdf")
|
||||
|
||||
@property
|
||||
def send(self) -> None:
|
||||
print_document(self.filename + ".pdf")
|
||||
log.debug("Document sent to printer")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
entries = [
|
||||
"Lüsebrink (Theorie und Praxis der Leichtathletik)",
|
||||
"Kulovics (ISP-Betreuung)",
|
||||
"Köhler (Ausgewählte Aspekte der materiellen Kultur Textil)",
|
||||
"Grau (Young Adult Literature)",
|
||||
"Schiebel (Bewegung II:Ausgewählte Problemfelder)",
|
||||
"Schiebel (Ernährungswiss. Perspektive)",
|
||||
"Park (Kommunikation und Kooperation)",
|
||||
"Schiebel (Schwimmen)",
|
||||
"Huppertz (Philosophieren mit Kindern)",
|
||||
"Heyl (Heyl)",
|
||||
"Reuter (Verschiedene Veranstaltungen)",
|
||||
"Reinhold (Arithmetik und mathematisches Denken)",
|
||||
"Wirtz (Forschungsmethoden)",
|
||||
"Schleider (Essstörungen)",
|
||||
"Schleider (Klinische Psychologie)",
|
||||
"Schleider (Doktorandenkolloquium)",
|
||||
"Schleider (Störungen Sozialverhaltens/Delinquenz)",
|
||||
"Burth (EU Forschung im Int. Vergleich/EU Gegenstand biling. Didaktik)",
|
||||
"Reinhardt (Einführung Politikdidaktik)",
|
||||
"Schleider (Psychologische Interventionsmethoden)",
|
||||
"Schleider (ADHS)",
|
||||
"Schleider (Beratung und Teamarbeit)",
|
||||
"Schleider (LRS)",
|
||||
"Schleider (Gesundheitspsychologie)",
|
||||
"Schleider (Elterntraining)",
|
||||
"Wulff (Hochschulzertifikat DaZ)",
|
||||
"Dinkelaker ( )",
|
||||
"Droll (Einführung in die Sprachwissenschaft)",
|
||||
"Karoß (Gymnastik - Sich Bewegen mit und ohne Handgeräte)",
|
||||
"Sahrai (Kindheit und Gesellschaft)",
|
||||
]
|
||||
doc = SemapSchilder(entries)
|
||||
631
src/logic/SRU.py
Normal file
@@ -0,0 +1,631 @@
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, Iterable, List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
# centralized logging used via src.shared.logging
|
||||
from src.logic.dataclass import BookData
|
||||
from src.shared.logging import log
|
||||
|
||||
log # ensure imported logger is referenced
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Dataclasses
|
||||
# -----------------------
|
||||
|
||||
|
||||
# --- MARC XML structures ---
|
||||
@dataclass
|
||||
class ControlField:
|
||||
tag: str
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubField:
|
||||
code: str
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataField:
|
||||
tag: str
|
||||
ind1: str = " "
|
||||
ind2: str = " "
|
||||
subfields: List[SubField] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarcRecord:
|
||||
leader: str
|
||||
controlfields: List[ControlField] = field(default_factory=list)
|
||||
datafields: List[DataField] = field(default_factory=list)
|
||||
|
||||
|
||||
# --- SRU record wrapper ---
|
||||
@dataclass
|
||||
class Record:
|
||||
recordSchema: str
|
||||
recordPacking: str
|
||||
recordData: MarcRecord
|
||||
recordPosition: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class EchoedSearchRequest:
|
||||
version: str
|
||||
query: str
|
||||
maximumRecords: int
|
||||
recordPacking: str
|
||||
recordSchema: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchRetrieveResponse:
|
||||
version: str
|
||||
numberOfRecords: int
|
||||
records: List[Record] = field(default_factory=list)
|
||||
echoedSearchRetrieveRequest: Optional[EchoedSearchRequest] = None
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Parser
|
||||
# -----------------------
|
||||
|
||||
ZS = "http://www.loc.gov/zing/srw/"
|
||||
MARC = "http://www.loc.gov/MARC21/slim"
|
||||
NS = {"zs": ZS, "marc": MARC}
|
||||
|
||||
|
||||
def _text(elem: Optional[ET.Element]) -> str:
|
||||
return (elem.text or "") if elem is not None else ""
|
||||
|
||||
|
||||
def _req_text(parent: ET.Element, path: str) -> Optional[str]:
|
||||
el = parent.find(path, NS)
|
||||
if el is None or el.text is None:
|
||||
return None
|
||||
return el.text
|
||||
|
||||
|
||||
def parse_marc_record(record_el: ET.Element) -> MarcRecord:
|
||||
"""
|
||||
record_el is the <marc:record> element (default ns MARC in your sample)
|
||||
"""
|
||||
# leader
|
||||
leader_text = _req_text(record_el, "marc:leader") or ""
|
||||
|
||||
# controlfields
|
||||
controlfields: List[ControlField] = []
|
||||
for cf in record_el.findall("marc:controlfield", NS):
|
||||
tag = cf.get("tag", "").strip()
|
||||
controlfields.append(ControlField(tag=tag, value=_text(cf)))
|
||||
|
||||
# datafields
|
||||
datafields: List[DataField] = []
|
||||
for df in record_el.findall("marc:datafield", NS):
|
||||
tag = df.get("tag", "").strip()
|
||||
ind1 = df.get("ind1") or " "
|
||||
ind2 = df.get("ind2") or " "
|
||||
subfields: List[SubField] = []
|
||||
for sf in df.findall("marc:subfield", NS):
|
||||
code = sf.get("code", "")
|
||||
subfields.append(SubField(code=code, value=_text(sf)))
|
||||
datafields.append(DataField(tag=tag, ind1=ind1, ind2=ind2, subfields=subfields))
|
||||
|
||||
return MarcRecord(
|
||||
leader=leader_text, controlfields=controlfields, datafields=datafields
|
||||
)
|
||||
|
||||
|
||||
def parse_record(zs_record_el: ET.Element) -> Record:
|
||||
recordSchema = _req_text(zs_record_el, "zs:recordSchema") or ""
|
||||
recordPacking = _req_text(zs_record_el, "zs:recordPacking") or ""
|
||||
|
||||
# recordData contains a MARC <record> with default MARC namespace in your sample
|
||||
recordData_el = zs_record_el.find("zs:recordData", NS)
|
||||
if recordData_el is None:
|
||||
raise ValueError("Missing zs:recordData")
|
||||
|
||||
marc_record_el = recordData_el.find("marc:record", NS)
|
||||
if marc_record_el is None:
|
||||
# If the MARC record uses default ns (xmlns="...") ElementTree still needs the ns-qualified name
|
||||
# We already searched with prefix; this covers both default and prefixed cases.
|
||||
raise ValueError("Missing MARC21 record inside zs:recordData")
|
||||
|
||||
marc_record = parse_marc_record(marc_record_el)
|
||||
|
||||
recordPosition = int(_req_text(zs_record_el, "zs:recordPosition") or "0")
|
||||
return Record(
|
||||
recordSchema=recordSchema,
|
||||
recordPacking=recordPacking,
|
||||
recordData=marc_record,
|
||||
recordPosition=recordPosition,
|
||||
)
|
||||
|
||||
|
||||
def parse_echoed_request(root: ET.Element) -> Optional[EchoedSearchRequest]:
|
||||
el = root.find("zs:echoedSearchRetrieveRequest", NS)
|
||||
if el is None:
|
||||
return None
|
||||
|
||||
# Be permissive with missing fields
|
||||
version = _text(el.find("zs:version", NS))
|
||||
query = _text(el.find("zs:query", NS))
|
||||
maximumRecords_text = _text(el.find("zs:maximumRecords", NS)) or "0"
|
||||
recordPacking = _text(el.find("zs:recordPacking", NS))
|
||||
recordSchema = _text(el.find("zs:recordSchema", NS))
|
||||
|
||||
try:
|
||||
maximumRecords = int(maximumRecords_text)
|
||||
except ValueError:
|
||||
maximumRecords = 0
|
||||
|
||||
return EchoedSearchRequest(
|
||||
version=version,
|
||||
query=query,
|
||||
maximumRecords=maximumRecords,
|
||||
recordPacking=recordPacking,
|
||||
recordSchema=recordSchema,
|
||||
)
|
||||
|
||||
|
||||
def parse_search_retrieve_response(
|
||||
xml_str: Union[str, bytes],
|
||||
) -> SearchRetrieveResponse:
|
||||
root = ET.fromstring(xml_str)
|
||||
|
||||
# Root is zs:searchRetrieveResponse
|
||||
version = _req_text(root, "zs:version")
|
||||
numberOfRecords = int(_req_text(root, "zs:numberOfRecords") or "0")
|
||||
|
||||
records_parent = root.find("zs:records", NS)
|
||||
records: List[Record] = []
|
||||
if records_parent is not None:
|
||||
for r in records_parent.findall("zs:record", NS):
|
||||
records.append(parse_record(r))
|
||||
|
||||
echoed = parse_echoed_request(root)
|
||||
|
||||
return SearchRetrieveResponse(
|
||||
version=version,
|
||||
numberOfRecords=numberOfRecords,
|
||||
records=records,
|
||||
echoedSearchRetrieveRequest=echoed,
|
||||
)
|
||||
|
||||
|
||||
# --- Query helpers over MarcRecord ---
|
||||
|
||||
|
||||
def iter_datafields(
|
||||
rec: MarcRecord,
|
||||
tag: Optional[str] = None,
|
||||
ind1: Optional[str] = None,
|
||||
ind2: Optional[str] = None,
|
||||
) -> Iterable[DataField]:
|
||||
"""Yield datafields, optionally filtered by tag/indicators."""
|
||||
for df in rec.datafields:
|
||||
if tag is not None and df.tag != tag:
|
||||
continue
|
||||
if ind1 is not None and df.ind1 != ind1:
|
||||
continue
|
||||
if ind2 is not None and df.ind2 != ind2:
|
||||
continue
|
||||
yield df
|
||||
|
||||
|
||||
def subfield_values(
|
||||
rec: MarcRecord,
|
||||
tag: str,
|
||||
code: str,
|
||||
*,
|
||||
ind1: Optional[str] = None,
|
||||
ind2: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""All values for subfield `code` in every `tag` field (respecting indicators)."""
|
||||
out: List[str] = []
|
||||
for df in iter_datafields(rec, tag, ind1, ind2):
|
||||
out.extend(sf.value for sf in df.subfields if sf.code == code)
|
||||
return out
|
||||
|
||||
|
||||
def first_subfield_value(
|
||||
rec: MarcRecord,
|
||||
tag: str,
|
||||
code: str,
|
||||
*,
|
||||
ind1: Optional[str] = None,
|
||||
ind2: Optional[str] = None,
|
||||
default: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""First value for subfield `code` in `tag` (respecting indicators)."""
|
||||
for df in iter_datafields(rec, tag, ind1, ind2):
|
||||
for sf in df.subfields:
|
||||
if sf.code == code:
|
||||
return sf.value
|
||||
return default
|
||||
|
||||
|
||||
def find_datafields_with_subfields(
|
||||
rec: MarcRecord,
|
||||
tag: str,
|
||||
*,
|
||||
where_all: Optional[Dict[str, str]] = None,
|
||||
where_any: Optional[Dict[str, str]] = None,
|
||||
casefold: bool = False,
|
||||
ind1: Optional[str] = None,
|
||||
ind2: Optional[str] = None,
|
||||
) -> List[DataField]:
|
||||
"""
|
||||
Return datafields of `tag` whose subfields match constraints:
|
||||
- where_all: every (code -> exact value) must be present
|
||||
- where_any: at least one (code -> exact value) present
|
||||
Set `casefold=True` for case-insensitive comparison.
|
||||
"""
|
||||
where_all = where_all or {}
|
||||
where_any = where_any or {}
|
||||
matched: List[DataField] = []
|
||||
|
||||
for df in iter_datafields(rec, tag, ind1, ind2):
|
||||
# Map code -> list of values (with optional casefold applied)
|
||||
vals: Dict[str, List[str]] = {}
|
||||
for sf in df.subfields:
|
||||
v = sf.value.casefold() if casefold else sf.value
|
||||
vals.setdefault(sf.code, []).append(v)
|
||||
|
||||
ok = True
|
||||
for c, v in where_all.items():
|
||||
vv = v.casefold() if casefold else v
|
||||
if c not in vals or vv not in vals[c]:
|
||||
ok = False
|
||||
break
|
||||
|
||||
if ok and where_any:
|
||||
any_ok = any(
|
||||
(c in vals) and ((v.casefold() if casefold else v) in vals[c])
|
||||
for c, v in where_any.items()
|
||||
)
|
||||
if not any_ok:
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
matched.append(df)
|
||||
|
||||
return matched
|
||||
|
||||
|
||||
def controlfield_value(
|
||||
rec: MarcRecord, tag: str, default: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Get the first controlfield value by tag (e.g., '001', '005')."""
|
||||
for cf in rec.controlfields:
|
||||
if cf.tag == tag:
|
||||
return cf.value
|
||||
return default
|
||||
|
||||
|
||||
def datafields_value(
|
||||
data: List[DataField], code: str, default: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Get the first value for a specific subfield code in a list of datafields."""
|
||||
for df in data:
|
||||
for sf in df.subfields:
|
||||
if sf.code == code:
|
||||
return sf.value
|
||||
return default
|
||||
|
||||
|
||||
def datafield_value(
|
||||
df: DataField, code: str, default: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Get the first value for a specific subfield code in a datafield."""
|
||||
for sf in df.subfields:
|
||||
if sf.code == code:
|
||||
return sf.value
|
||||
return default
|
||||
|
||||
|
||||
def _smart_join_title(a: str, b: Optional[str]) -> str:
|
||||
"""
|
||||
Join 245 $a and $b with MARC-style punctuation.
|
||||
If $b is present, join with ' : ' unless either side already supplies punctuation.
|
||||
"""
|
||||
a = a.strip()
|
||||
if not b:
|
||||
return a
|
||||
b = b.strip()
|
||||
if a.endswith((":", ";", "/")) or b.startswith((":", ";", "/")):
|
||||
return f"{a} {b}"
|
||||
return f"{a} : {b}"
|
||||
|
||||
|
||||
def subfield_values_from_fields(
|
||||
fields: Iterable[DataField],
|
||||
code: str,
|
||||
) -> List[str]:
|
||||
"""All subfield values with given `code` across a list of DataField."""
|
||||
return [sf.value for df in fields for sf in df.subfields if sf.code == code]
|
||||
|
||||
|
||||
def first_subfield_value_from_fields(
|
||||
fields: Iterable[DataField],
|
||||
code: str,
|
||||
default: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""First subfield value with given `code` across a list of DataField."""
|
||||
for df in fields:
|
||||
for sf in df.subfields:
|
||||
if sf.code == code:
|
||||
return sf.value
|
||||
return default
|
||||
|
||||
|
||||
def subfield_value_pairs_from_fields(
|
||||
fields: Iterable[DataField],
|
||||
code: str,
|
||||
) -> List[Tuple[DataField, str]]:
|
||||
"""
|
||||
Return (DataField, value) pairs for all subfields with `code`.
|
||||
Useful if you need to know which field a value came from.
|
||||
"""
|
||||
out: List[Tuple[DataField, str]] = []
|
||||
for df in fields:
|
||||
for sf in df.subfields:
|
||||
if sf.code == code:
|
||||
out.append((df, sf.value))
|
||||
return out
|
||||
|
||||
|
||||
def book_from_marc(rec: MarcRecord) -> BookData:
|
||||
# PPN from controlfield 001
|
||||
ppn = controlfield_value(rec, "001")
|
||||
|
||||
# Title = 245 $a + 245 $b (if present)
|
||||
t_a = first_subfield_value(rec, "245", "a")
|
||||
t_b = first_subfield_value(rec, "245", "b")
|
||||
title = _smart_join_title(t_a, t_b) if t_a else None
|
||||
|
||||
# Signature = 924 where $9 == "Frei 129" → take that field's $g
|
||||
frei_fields = find_datafields_with_subfields(
|
||||
rec, "924", where_all={"9": "Frei 129"}
|
||||
)
|
||||
signature = first_subfield_value_from_fields(frei_fields, "g")
|
||||
|
||||
# Year = 264 $c (prefer ind2="1" publication; fallback to any 264)
|
||||
year = first_subfield_value(rec, "264", "c", ind2="1") or first_subfield_value(
|
||||
rec, "264", "c"
|
||||
)
|
||||
isbn = subfield_values(rec, "020", "a")
|
||||
mediatype = first_subfield_value(rec, "338", "a")
|
||||
lang = subfield_values(rec, "041", "a")
|
||||
authors = subfield_values(rec, "700", "a")
|
||||
author = None
|
||||
if authors:
|
||||
author = "; ".join(authors)
|
||||
|
||||
return BookData(
|
||||
ppn=ppn,
|
||||
title=title,
|
||||
signature=signature,
|
||||
edition=first_subfield_value(rec, "250", "a") or "",
|
||||
year=year,
|
||||
pages=first_subfield_value(rec, "300", "a") or "",
|
||||
publisher=first_subfield_value(rec, "264", "b") or "",
|
||||
isbn=isbn,
|
||||
language=lang,
|
||||
link="",
|
||||
author=author,
|
||||
media_type=mediatype,
|
||||
)
|
||||
|
||||
|
||||
class SWBData(Enum):
|
||||
URL = "https://sru.k10plus.de/opac-de-627!rec=1?version=1.1&operation=searchRetrieve&query={}&maximumRecords=100&recordSchema=marcxml"
|
||||
ARGSCHEMA = "pica."
|
||||
NAME = "SWB"
|
||||
|
||||
|
||||
class DNBData(Enum):
|
||||
URL = "https://services.dnb.de/sru/dnb?version=1.1&operation=searchRetrieve&query={}&maximumRecords=100&recordSchema=MARC21-xml"
|
||||
ARGSCHEMA = ""
|
||||
NAME = "DNB"
|
||||
|
||||
|
||||
class SRUSite(Enum):
|
||||
SWB = SWBData
|
||||
DNB = DNBData
|
||||
|
||||
|
||||
RVK_ALLOWED = r"[A-Z0-9.\-\/]" # conservative char set typically seen in RVK notations
|
||||
|
||||
|
||||
def find_newer_edition(
|
||||
swb_result: BookData, dnb_result: List[BookData]
|
||||
) -> Optional[List[BookData]]:
|
||||
"""
|
||||
New edition if:
|
||||
- year > swb.year OR
|
||||
- edition_number > swb.edition_number
|
||||
|
||||
Additional guards & preferences:
|
||||
- If both have signatures and they differ, skip (not the same work).
|
||||
- For duplicates (same ppn): keep the one that has a signature, and
|
||||
prefer a signature that matches swb_result.signature.
|
||||
- If multiple remain: keep the single 'latest' by (year desc,
|
||||
edition_number desc, best-signature-match desc, has-signature desc).
|
||||
"""
|
||||
|
||||
def norm_sig(s: Optional[str]) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
# normalize: lowercase, collapse whitespace, keep alnum + a few separators
|
||||
s = s.lower()
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
# remove obvious noise; adjust if your signature format differs
|
||||
s = re.sub(r"[^a-z0-9\-_/\. ]+", "", s)
|
||||
return s
|
||||
|
||||
def has_sig(b: BookData) -> bool:
|
||||
return bool(getattr(b, "signature", None))
|
||||
|
||||
def sig_matches_swb(b: BookData) -> bool:
|
||||
if not has_sig(b) or not has_sig(swb_result):
|
||||
return False
|
||||
return norm_sig(b.signature) == norm_sig(swb_result.signature)
|
||||
|
||||
def strictly_newer(b: BookData) -> bool:
|
||||
by_year = (
|
||||
b.year is not None
|
||||
and swb_result.year is not None
|
||||
and b.year > swb_result.year
|
||||
)
|
||||
by_edition = (
|
||||
b.edition_number is not None
|
||||
and swb_result.edition_number is not None
|
||||
and b.edition_number > swb_result.edition_number
|
||||
)
|
||||
return by_year or by_edition
|
||||
|
||||
swb_sig_norm = norm_sig(getattr(swb_result, "signature", None))
|
||||
|
||||
# 1) Filter to same-work AND newer
|
||||
candidates: List[BookData] = []
|
||||
for b in dnb_result:
|
||||
# Skip if both signatures exist and don't match (different work)
|
||||
b_sig = getattr(b, "signature", None)
|
||||
if b_sig and swb_result.signature:
|
||||
if norm_sig(b_sig) != swb_sig_norm:
|
||||
continue # not the same work
|
||||
|
||||
# Keep only if newer by rules
|
||||
if strictly_newer(b):
|
||||
candidates.append(b)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 2) Dedupe by PPN, preferring signature (and matching signature if possible)
|
||||
by_ppn: dict[Optional[str], BookData] = {}
|
||||
for b in candidates:
|
||||
key = getattr(b, "ppn", None)
|
||||
prev = by_ppn.get(key)
|
||||
if prev is None:
|
||||
by_ppn[key] = b
|
||||
continue
|
||||
|
||||
# Compute preference score for both
|
||||
def ppn_pref_score(x: BookData) -> tuple[int, int]:
|
||||
# (signature matches swb, has signature)
|
||||
return (1 if sig_matches_swb(x) else 0, 1 if has_sig(x) else 0)
|
||||
|
||||
if ppn_pref_score(b) > ppn_pref_score(prev):
|
||||
by_ppn[key] = b
|
||||
|
||||
deduped = list(by_ppn.values())
|
||||
if not deduped:
|
||||
return None
|
||||
|
||||
# 3) If multiple remain, keep only the latest one.
|
||||
# Order: year desc, edition_number desc, signature-match desc, has-signature desc
|
||||
def sort_key(b: BookData):
|
||||
year = b.year if b.year is not None else -1
|
||||
ed = b.edition_number if b.edition_number is not None else -1
|
||||
sig_match = 1 if sig_matches_swb(b) else 0
|
||||
sig_present = 1 if has_sig(b) else 0
|
||||
return (year, ed, sig_match, sig_present)
|
||||
|
||||
best = max(deduped, key=sort_key)
|
||||
return [best] if best else None
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, site: str, url: str, prefix: str):
|
||||
self.site = site
|
||||
self.url = url
|
||||
self.prefix = prefix
|
||||
# Reuse TCP connections across requests for better performance
|
||||
self._session = requests.Session()
|
||||
# Slightly larger connection pool for concurrent calls
|
||||
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=20)
|
||||
self._session.mount("http://", adapter)
|
||||
self._session.mount("https://", adapter)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __del__(self):
|
||||
# Best-effort cleanup
|
||||
self.close()
|
||||
|
||||
def get(self, query_args: Iterable[str]) -> List[Record]:
|
||||
# if any query_arg ends with =, remove it
|
||||
if self.site == "DNB":
|
||||
args = [arg for arg in query_args if not arg.startswith("pica.")]
|
||||
if args == []:
|
||||
raise ValueError("DNB queries must include at least one search term")
|
||||
query_args = args
|
||||
# query_args = [f"{self.prefix}{arg}" for arg in query_args]
|
||||
query = "+and+".join(query_args)
|
||||
query = query.replace(" ", "%20").replace("&", "%26")
|
||||
# query_args = [arg for arg in query_args if not arg.endswith("=")]
|
||||
# query = "+and+".join(query_args)
|
||||
# query = query.replace(" ", "%20").replace("&", "%26")
|
||||
# insert the query into the url url is
|
||||
url = self.url.format(query)
|
||||
|
||||
log.debug(url)
|
||||
headers = {
|
||||
"User-Agent": f"{self.site} SRU Client, <alexander.kirchner@ph-freiburg.de>",
|
||||
"Accept": "application/xml",
|
||||
"Accept-Charset": "latin1,utf-8;q=0.7,*;q=0.3",
|
||||
}
|
||||
# Use persistent session and set timeouts to avoid hanging
|
||||
resp = self._session.get(url, headers=headers, timeout=(3.05, 60))
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"Error fetching data from SWB: {resp.status_code}")
|
||||
# Parse using raw bytes (original behavior) to preserve encoding edge cases
|
||||
sr = parse_search_retrieve_response(resp.content)
|
||||
return sr.records
|
||||
|
||||
def getBooks(self, query_args: Iterable[str]) -> List[BookData]:
|
||||
records: List[Record] = self.get(query_args)
|
||||
# Avoid printing on hot paths; rely on logger if needed
|
||||
log.debug(f"{self.site} found {len(records)} records for args={query_args}")
|
||||
books: List[BookData] = []
|
||||
# extract title from query_args if present
|
||||
title = None
|
||||
for arg in query_args:
|
||||
if arg.startswith("pica.tit="):
|
||||
title = arg.split("=")[1]
|
||||
break
|
||||
for rec in records:
|
||||
book = book_from_marc(rec.recordData)
|
||||
books.append(book)
|
||||
if title:
|
||||
books = [
|
||||
b
|
||||
for b in books
|
||||
if b.title and b.title.lower().startswith(title.lower())
|
||||
]
|
||||
return books
|
||||
|
||||
def getLinkForBook(self, book: BookData) -> str:
|
||||
# Not implemented: depends on catalog front-end; return empty string for now
|
||||
return ""
|
||||
|
||||
|
||||
class SWB(Api):
|
||||
def __init__(self):
|
||||
self.site = SWBData.NAME.value
|
||||
self.url = SWBData.URL.value
|
||||
self.prefix = SWBData.ARGSCHEMA.value
|
||||
super().__init__(self.site, self.url, self.prefix)
|
||||
@@ -1,6 +1,35 @@
|
||||
from .dataclass import ApparatData, BookData, Prof, Apparat, ELSA
|
||||
__all__ = [
|
||||
"custom_sort",
|
||||
"sort_semesters_list",
|
||||
"APP_NRS",
|
||||
"PROF_TITLES",
|
||||
"SEMAP_MEDIA_ACCOUNTS",
|
||||
"csv_to_list",
|
||||
"ELSA",
|
||||
"Apparat",
|
||||
"ApparatData",
|
||||
"BookData",
|
||||
"Prof",
|
||||
"Semester",
|
||||
"SemapDocument",
|
||||
"elsa_word_to_csv",
|
||||
"pdf_to_semap",
|
||||
"word_docx_to_csv",
|
||||
"word_to_semap",
|
||||
"ZoteroController",
|
||||
"eml_to_semap",
|
||||
]
|
||||
from .c_sort import custom_sort, sort_semesters_list
|
||||
from .constants import APP_NRS, PROF_TITLES, SEMAP_MEDIA_ACCOUNTS
|
||||
from .csvparser import csv_to_list
|
||||
from .wordparser import elsa_word_to_csv, word_docx_to_csv
|
||||
from .dataclass import ELSA, Apparat, ApparatData, BookData, Prof
|
||||
from .semester import Semester
|
||||
from .wordparser import (
|
||||
SemapDocument,
|
||||
elsa_word_to_csv,
|
||||
pdf_to_semap,
|
||||
word_docx_to_csv,
|
||||
word_to_semap,
|
||||
)
|
||||
from .xmlparser import eml_to_semap
|
||||
from .zotero import ZoteroController
|
||||
|
||||
@@ -1,36 +1,4 @@
|
||||
def parse_semester(semester: str):
|
||||
"""
|
||||
Parses the semester string into a sortable format.
|
||||
Returns a tuple of (year, type), where type is 0 for SoSe and 1 for WiSe.
|
||||
"""
|
||||
if semester.startswith("SoSe"):
|
||||
return int(semester.split()[1]), 0
|
||||
elif semester.startswith("WiSe"):
|
||||
year_part = semester.split()[1]
|
||||
start_year, _ = map(int, year_part.split("/"))
|
||||
return start_year, 1
|
||||
else:
|
||||
raise ValueError(f"Invalid semester format: {semester}")
|
||||
|
||||
|
||||
def custom_sort(entries):
|
||||
"""
|
||||
Sorts the list of tuples based on the custom schema.
|
||||
|
||||
:param entries: List of tuples in the format (str, int, int).
|
||||
:return: Sorted list of tuples.
|
||||
"""
|
||||
return sorted(
|
||||
entries,
|
||||
key=lambda entry: (
|
||||
parse_semester(entry[0]), # Sort by semester parsed as (year, type)
|
||||
entry[1], # Then by the second element of the tuple
|
||||
entry[2], # Finally by the third element of the tuple
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_semester(semester: str):
|
||||
def parse_semester(semester: str) -> tuple[int, int]:
|
||||
"""
|
||||
Parses the semester string into a sortable format.
|
||||
Returns a tuple of (year, type), where type is 0 for SoSe and 1 for WiSe.
|
||||
@@ -48,6 +16,23 @@ def parse_semester(semester: str):
|
||||
raise ValueError(f"Invalid semester format: {semester}")
|
||||
|
||||
|
||||
def custom_sort(entries) -> list:
|
||||
"""
|
||||
Sorts the list of tuples based on the custom schema.
|
||||
|
||||
:param entries: List of tuples in the format (str, int, int).
|
||||
:return: Sorted list of tuples.
|
||||
"""
|
||||
return sorted(
|
||||
entries,
|
||||
key=lambda entry: (
|
||||
parse_semester(entry[0]), # Sort by semester parsed as (year, type)
|
||||
entry[1], # Then by the second element of the tuple
|
||||
entry[2], # Finally by the third element of the tuple
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def sort_semesters_list(semesters: list) -> list:
|
||||
"""
|
||||
Sorts a list of semester strings based on year and type.
|
||||
@@ -83,4 +68,4 @@ if __name__ == "__main__":
|
||||
"SoSe 25",
|
||||
]
|
||||
|
||||
print(sort_semesters_list(unsorted))
|
||||
# print(sort_semesters_list(unsorted))
|
||||
|
||||
@@ -30,184 +30,184 @@ PROF_TITLES = [
|
||||
]
|
||||
|
||||
SEMAP_MEDIA_ACCOUNTS = {
|
||||
"1": "1008000055",
|
||||
"2": "1008000188",
|
||||
"3": "1008000211",
|
||||
"4": "1008000344",
|
||||
"5": "1008000477",
|
||||
"6": "1008000500",
|
||||
"7": "1008000633",
|
||||
"8": "1008000766",
|
||||
"9": "1008000899",
|
||||
"10": "1008000922",
|
||||
"11": "1008001044",
|
||||
"12": "1008001177",
|
||||
"13": "1008001200",
|
||||
"14": "1008001333",
|
||||
"15": "1008001466",
|
||||
"16": "1008001599",
|
||||
"17": "1008001622",
|
||||
"18": "1008001755",
|
||||
"19": "1008001888",
|
||||
"20": "1008001911",
|
||||
"21": "1008002033",
|
||||
"22": "1008002166",
|
||||
"23": "1008002299",
|
||||
"24": "1008002322",
|
||||
"25": "1008002455",
|
||||
"26": "1008002588",
|
||||
"27": "1008002611",
|
||||
"28": "1008002744",
|
||||
"29": "1008002877",
|
||||
"30": "1008002900",
|
||||
"31": "1008003022",
|
||||
"32": "1008003155",
|
||||
"33": "1008003288",
|
||||
"34": "1008003311",
|
||||
"35": "1008003444",
|
||||
"36": "1008003577",
|
||||
"37": "1008003600",
|
||||
"38": "1008003733",
|
||||
"39": "1008003866",
|
||||
"40": "1008003999",
|
||||
"41": "1008004011",
|
||||
"42": "1008004144",
|
||||
"43": "1008004277",
|
||||
"44": "1008004300",
|
||||
"45": "1008004433",
|
||||
"46": "1008004566",
|
||||
"47": "1008004699",
|
||||
"48": "1008004722",
|
||||
"49": "1008004855",
|
||||
"50": "1008004988",
|
||||
"51": "1008005000",
|
||||
"52": "1008005133",
|
||||
"53": "1008005266",
|
||||
"54": "1008005399",
|
||||
"55": "1008005422",
|
||||
"56": "1008005555",
|
||||
"57": "1008005688",
|
||||
"58": "1008005711",
|
||||
"59": "1008005844",
|
||||
"60": "1008005977",
|
||||
"61": "1008006099",
|
||||
"62": "1008006122",
|
||||
"63": "1008006255",
|
||||
"64": "1008006388",
|
||||
"65": "1008006411",
|
||||
"66": "1008006544",
|
||||
"67": "1008006677",
|
||||
"68": "1008006700",
|
||||
"69": "1008006833",
|
||||
"70": "1008006966",
|
||||
"71": "1008007088",
|
||||
"72": "1008007111",
|
||||
"73": "1008007244",
|
||||
"74": "1008007377",
|
||||
"75": "1008007400",
|
||||
"76": "1008007533",
|
||||
"77": "1008007666",
|
||||
"78": "1008007799",
|
||||
"79": "1008007822",
|
||||
"80": "1008007955",
|
||||
"81": "1008008077",
|
||||
"82": "1008008100",
|
||||
"83": "1008008233",
|
||||
"84": "1008008366",
|
||||
"85": "1008008499",
|
||||
"86": "1008008522",
|
||||
"87": "1008008655",
|
||||
"88": "1008008788",
|
||||
"89": "1008008811",
|
||||
"90": "1008008944",
|
||||
"91": "1008009066",
|
||||
"92": "1008009199",
|
||||
"93": "1008009222",
|
||||
"94": "1008009355",
|
||||
"95": "1008009488",
|
||||
"96": "1008009511",
|
||||
"97": "1008009644",
|
||||
"98": "1008009777",
|
||||
"99": "1008009800",
|
||||
"100": "1008009933",
|
||||
"101": "1008010022",
|
||||
"102": "1008010155",
|
||||
"103": "1008010288",
|
||||
"104": "1008010311",
|
||||
"105": "1008010444",
|
||||
"106": "1008010577",
|
||||
"107": "1008010600",
|
||||
"108": "1008010733",
|
||||
"109": "1008010866",
|
||||
"110": "1008010999",
|
||||
"111": "1008011011",
|
||||
"112": "1008011144",
|
||||
"113": "1008011277",
|
||||
"114": "1008011300",
|
||||
"115": "1008011433",
|
||||
"116": "1008011566",
|
||||
"117": "1008011699",
|
||||
"118": "1008011722",
|
||||
"119": "1008011855",
|
||||
"120": "1008011988",
|
||||
"121": "1008012000",
|
||||
"122": "1008012133",
|
||||
"123": "1008012266",
|
||||
"124": "1008012399",
|
||||
"125": "1008012422",
|
||||
"126": "1008012555",
|
||||
"127": "1008012688",
|
||||
"128": "1008012711",
|
||||
"129": "1008012844",
|
||||
"130": "1008012977",
|
||||
"131": "1008013099",
|
||||
"132": "1008013122",
|
||||
"133": "1008013255",
|
||||
"134": "1008013388",
|
||||
"135": "1008013411",
|
||||
"136": "1008013544",
|
||||
"137": "1008013677",
|
||||
"138": "1008013700",
|
||||
"139": "1008013833",
|
||||
"140": "1008013966",
|
||||
"141": "1008014088",
|
||||
"142": "1008014111",
|
||||
"143": "1008014244",
|
||||
"144": "1008014377",
|
||||
"145": "1008014400",
|
||||
"146": "1008014533",
|
||||
"147": "1008014666",
|
||||
"148": "1008014799",
|
||||
"149": "1008014822",
|
||||
"150": "1008014955",
|
||||
"151": "1008015077",
|
||||
"152": "1008015100",
|
||||
"153": "1008015233",
|
||||
"154": "1008015366",
|
||||
"155": "1008015499",
|
||||
"156": "1008015522",
|
||||
"157": "1008015655",
|
||||
"158": "1008015788",
|
||||
"159": "1008015811",
|
||||
"160": "1008015944",
|
||||
"161": "1008016066",
|
||||
"162": "1008016199",
|
||||
"163": "1008016222",
|
||||
"164": "1008016355",
|
||||
"165": "1008016488",
|
||||
"166": "1008016511",
|
||||
"167": "1008016644",
|
||||
"168": "1008016777",
|
||||
"169": "1008016800",
|
||||
"170": "1008016933",
|
||||
"171": "1008017055",
|
||||
"172": "1008017188",
|
||||
"173": "1008017211",
|
||||
"174": "1008017344",
|
||||
"175": "1008017477",
|
||||
"176": "1008017500",
|
||||
"177": "1008017633",
|
||||
"178": "1008017766",
|
||||
"179": "1008017899",
|
||||
"180": "1008017922",
|
||||
1: "1008000055",
|
||||
2: "1008000188",
|
||||
3: "1008000211",
|
||||
4: "1008000344",
|
||||
5: "1008000477",
|
||||
6: "1008000500",
|
||||
7: "1008000633",
|
||||
8: "1008000766",
|
||||
9: "1008000899",
|
||||
10: "1008000922",
|
||||
11: "1008001044",
|
||||
12: "1008001177",
|
||||
13: "1008001200",
|
||||
14: "1008001333",
|
||||
15: "1008001466",
|
||||
16: "1008001599",
|
||||
17: "1008001622",
|
||||
18: "1008001755",
|
||||
19: "1008001888",
|
||||
20: "1008001911",
|
||||
21: "1008002033",
|
||||
22: "1008002166",
|
||||
23: "1008002299",
|
||||
24: "1008002322",
|
||||
25: "1008002455",
|
||||
26: "1008002588",
|
||||
27: "1008002611",
|
||||
28: "1008002744",
|
||||
29: "1008002877",
|
||||
30: "1008002900",
|
||||
31: "1008003022",
|
||||
32: "1008003155",
|
||||
33: "1008003288",
|
||||
34: "1008003311",
|
||||
35: "1008003444",
|
||||
36: "1008003577",
|
||||
37: "1008003600",
|
||||
38: "1008003733",
|
||||
39: "1008003866",
|
||||
40: "1008003999",
|
||||
41: "1008004011",
|
||||
42: "1008004144",
|
||||
43: "1008004277",
|
||||
44: "1008004300",
|
||||
45: "1008004433",
|
||||
46: "1008004566",
|
||||
47: "1008004699",
|
||||
48: "1008004722",
|
||||
49: "1008004855",
|
||||
50: "1008004988",
|
||||
51: "1008005000",
|
||||
52: "1008005133",
|
||||
53: "1008005266",
|
||||
54: "1008005399",
|
||||
55: "1008005422",
|
||||
56: "1008005555",
|
||||
57: "1008005688",
|
||||
58: "1008005711",
|
||||
59: "1008005844",
|
||||
60: "1008005977",
|
||||
61: "1008006099",
|
||||
62: "1008006122",
|
||||
63: "1008006255",
|
||||
64: "1008006388",
|
||||
65: "1008006411",
|
||||
66: "1008006544",
|
||||
67: "1008006677",
|
||||
68: "1008006700",
|
||||
69: "1008006833",
|
||||
70: "1008006966",
|
||||
71: "1008007088",
|
||||
72: "1008007111",
|
||||
73: "1008007244",
|
||||
74: "1008007377",
|
||||
75: "1008007400",
|
||||
76: "1008007533",
|
||||
77: "1008007666",
|
||||
78: "1008007799",
|
||||
79: "1008007822",
|
||||
80: "1008007955",
|
||||
81: "1008008077",
|
||||
82: "1008008100",
|
||||
83: "1008008233",
|
||||
84: "1008008366",
|
||||
85: "1008008499",
|
||||
86: "1008008522",
|
||||
87: "1008008655",
|
||||
88: "1008008788",
|
||||
89: "1008008811",
|
||||
90: "1008008944",
|
||||
91: "1008009066",
|
||||
92: "1008009199",
|
||||
93: "1008009222",
|
||||
94: "1008009355",
|
||||
95: "1008009488",
|
||||
96: "1008009511",
|
||||
97: "1008009644",
|
||||
98: "1008009777",
|
||||
99: "1008009800",
|
||||
100: "1008009933",
|
||||
101: "1008010022",
|
||||
102: "1008010155",
|
||||
103: "1008010288",
|
||||
104: "1008010311",
|
||||
105: "1008010444",
|
||||
106: "1008010577",
|
||||
107: "1008010600",
|
||||
108: "1008010733",
|
||||
109: "1008010866",
|
||||
110: "1008010999",
|
||||
111: "1008011011",
|
||||
112: "1008011144",
|
||||
113: "1008011277",
|
||||
114: "1008011300",
|
||||
115: "1008011433",
|
||||
116: "1008011566",
|
||||
117: "1008011699",
|
||||
118: "1008011722",
|
||||
119: "1008011855",
|
||||
120: "1008011988",
|
||||
121: "1008012000",
|
||||
122: "1008012133",
|
||||
123: "1008012266",
|
||||
124: "1008012399",
|
||||
125: "1008012422",
|
||||
126: "1008012555",
|
||||
127: "1008012688",
|
||||
128: "1008012711",
|
||||
129: "1008012844",
|
||||
130: "1008012977",
|
||||
131: "1008013099",
|
||||
132: "1008013122",
|
||||
133: "1008013255",
|
||||
134: "1008013388",
|
||||
135: "1008013411",
|
||||
136: "1008013544",
|
||||
137: "1008013677",
|
||||
138: "1008013700",
|
||||
139: "1008013833",
|
||||
140: "1008013966",
|
||||
141: "1008014088",
|
||||
142: "1008014111",
|
||||
143: "1008014244",
|
||||
144: "1008014377",
|
||||
145: "1008014400",
|
||||
146: "1008014533",
|
||||
147: "1008014666",
|
||||
148: "1008014799",
|
||||
149: "1008014822",
|
||||
150: "1008014955",
|
||||
151: "1008015077",
|
||||
152: "1008015100",
|
||||
153: "1008015233",
|
||||
154: "1008015366",
|
||||
155: "1008015499",
|
||||
156: "1008015522",
|
||||
157: "1008015655",
|
||||
158: "1008015788",
|
||||
159: "1008015811",
|
||||
160: "1008015944",
|
||||
161: "1008016066",
|
||||
162: "1008016199",
|
||||
163: "1008016222",
|
||||
164: "1008016355",
|
||||
165: "1008016488",
|
||||
166: "1008016511",
|
||||
167: "1008016644",
|
||||
168: "1008016777",
|
||||
169: "1008016800",
|
||||
170: "1008016933",
|
||||
171: "1008017055",
|
||||
172: "1008017188",
|
||||
173: "1008017211",
|
||||
174: "1008017344",
|
||||
175: "1008017477",
|
||||
176: "1008017500",
|
||||
177: "1008017633",
|
||||
178: "1008017766",
|
||||
179: "1008017899",
|
||||
180: "1008017922",
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import csv
|
||||
|
||||
import chardet
|
||||
from charset_normalizer import detect
|
||||
|
||||
|
||||
def csv_to_list(path: str) -> list[str]:
|
||||
"""
|
||||
Extracts the data from a csv file and returns it as a pandas dataframe
|
||||
"""
|
||||
encoding = chardet.detect(open(path, "rb").read())["encoding"]
|
||||
encoding = detect(open(path, "rb").read())["encoding"]
|
||||
with open(path, newline="", encoding=encoding) as csvfile:
|
||||
# if decoder fails to map, assign ""
|
||||
reader = csv.reader(csvfile, delimiter=";", quotechar="|")
|
||||
@@ -20,4 +20,4 @@ def csv_to_list(path: str) -> list[str]:
|
||||
if __name__ == "__main__":
|
||||
text = csv_to_list("C:/Users/aky547/Desktop/semap/71.csv")
|
||||
# remove linebreaks
|
||||
# print(text)
|
||||
# #print(text)
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import re
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import regex
|
||||
|
||||
from src.logic.openai import name_tester, run_shortener, semester_converter
|
||||
from src.logic.semester import Semester
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prof:
|
||||
id: int = None
|
||||
_title: str = None
|
||||
firstname: str = None
|
||||
lastname: str = None
|
||||
fullname: str = None
|
||||
mail: str = None
|
||||
telnr: str = None
|
||||
id: Optional[int] = None
|
||||
_title: Optional[str] = None
|
||||
firstname: Optional[str] = None
|
||||
lastname: Optional[str] = None
|
||||
fullname: Optional[str] = None
|
||||
mail: Optional[str] = None
|
||||
telnr: Optional[str] = None
|
||||
|
||||
# add function that sets the data based on a dict
|
||||
def from_dict(self, data: dict):
|
||||
def from_dict(self, data: dict[str, Union[str, int]]):
|
||||
for key, value in data.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
return self
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
def title(self) -> str:
|
||||
if self._title is None or self._title == "None":
|
||||
return ""
|
||||
return self._title
|
||||
|
||||
@title.setter
|
||||
def title(self, value):
|
||||
def title(self, value: str):
|
||||
self._title = value
|
||||
|
||||
# add function that sets the data from a tuple
|
||||
def from_tuple(self, data: tuple):
|
||||
def from_tuple(self, data: tuple[Union[str, int], ...]) -> "Prof":
|
||||
setattr(self, "id", data[0])
|
||||
setattr(self, "_title", data[1])
|
||||
setattr(self, "firstname", data[2])
|
||||
@@ -42,7 +47,7 @@ class Prof:
|
||||
setattr(self, "telnr", data[6])
|
||||
return self
|
||||
|
||||
def name(self, comma=False):
|
||||
def name(self, comma: bool = False) -> Optional[str]:
|
||||
if self.firstname is None and self.lastname is None:
|
||||
if "," in self.fullname:
|
||||
self.firstname = self.fullname.split(",")[1].strip()
|
||||
@@ -62,46 +67,121 @@ class BookData:
|
||||
signature: str | None = None
|
||||
edition: str | None = None
|
||||
link: str | None = None
|
||||
isbn: str | list | None = field(default_factory=list)
|
||||
isbn: Union[str, list[str], None] = field(default_factory=list)
|
||||
author: str | None = None
|
||||
language: str | list | None = field(default_factory=list)
|
||||
language: Union[str, list[str], None] = field(default_factory=list)
|
||||
publisher: str | None = None
|
||||
place: str | None = None
|
||||
year: str | None = None
|
||||
year: int | None = None
|
||||
pages: str | None = None
|
||||
library_location: int | None = None
|
||||
library_location: str | None = None
|
||||
in_apparat: bool | None = False
|
||||
adis_idn: str | None = None
|
||||
old_book: Any | None = None
|
||||
media_type: str | None = None #
|
||||
in_library: bool | None = None # whether the book is in the library or not
|
||||
medianr: int | None = None # Media number in the library system
|
||||
|
||||
def from_dict(self, data: dict):
|
||||
def __post_init__(self):
|
||||
self.library_location = (
|
||||
str(self.library_location) if self.library_location else None
|
||||
)
|
||||
if isinstance(self.language, list) and self.language:
|
||||
self.language = [lang.strip() for lang in self.language if lang.strip()]
|
||||
self.language = ",".join(self.language)
|
||||
self.year = regex.sub(r"[^\d]", "", str(self.year)) if self.year else None
|
||||
self.in_library = True if self.signature else False
|
||||
|
||||
def from_dict(self, data: dict) -> "BookData":
|
||||
for key, value in data.items():
|
||||
setattr(self, key, value)
|
||||
return self
|
||||
|
||||
def to_dict(self):
|
||||
return self.__dict__
|
||||
def merge(self, other: "BookData") -> "BookData":
|
||||
for key, value in other.__dict__.items():
|
||||
# merge lists, if the attribute is a list, extend it
|
||||
if isinstance(value, list):
|
||||
current_value = getattr(self, key)
|
||||
if current_value is None:
|
||||
current_value = []
|
||||
elif not isinstance(current_value, list):
|
||||
current_value = [current_value]
|
||||
# extend the list with the new values, but only if they are not already in the list
|
||||
for v in value:
|
||||
if v not in current_value:
|
||||
current_value.append(v)
|
||||
setattr(self, key, current_value)
|
||||
if value is not None and (
|
||||
getattr(self, key) is None or getattr(self, key) == ""
|
||||
):
|
||||
setattr(self, key, value)
|
||||
# in language, drop all entries that are longer than 3 characters
|
||||
if isinstance(self.language, list):
|
||||
self.language = [lang for lang in self.language if len(lang) <= 4]
|
||||
return self
|
||||
|
||||
def from_dataclass(self, dataclass):
|
||||
@property
|
||||
def to_dict(self) -> str:
|
||||
"""Convert the dataclass to a dictionary."""
|
||||
data_dict = {
|
||||
key: value for key, value in self.__dict__.items() if value is not None
|
||||
}
|
||||
# remove old_book from data_dict
|
||||
if "old_book" in data_dict:
|
||||
del data_dict["old_book"]
|
||||
return json.dumps(data_dict, ensure_ascii=False)
|
||||
|
||||
def from_dataclass(self, dataclass: Optional[Any]) -> None:
|
||||
if dataclass is None:
|
||||
return
|
||||
for key, value in dataclass.__dict__.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def from_string(self, data: str):
|
||||
if not data.startswith("BookData"):
|
||||
raise ValueError("No valid BookData string")
|
||||
def get_book_type(self) -> str:
|
||||
if "Online" in self.pages:
|
||||
return "eBook"
|
||||
else:
|
||||
pattern = r"(\w+)='([^']*)'"
|
||||
data_dict = dict(re.findall(pattern, data))
|
||||
# print(data_dict)
|
||||
for key, value in data_dict.items():
|
||||
setattr(self, key, value)
|
||||
return "Druckausgabe"
|
||||
|
||||
def from_string(self, data: str) -> "BookData":
|
||||
ndata = json.loads(data)
|
||||
|
||||
return BookData(**ndata)
|
||||
|
||||
def from_LehmannsSearchResult(self, result: Any) -> "BookData":
|
||||
self.title = result.title
|
||||
self.author = "; ".join(result.authors) if result.authors else None
|
||||
self.edition = str(result.edition) if result.edition else None
|
||||
self.link = result.url
|
||||
self.isbn = (
|
||||
result.isbn13
|
||||
if isinstance(result.isbn13, list)
|
||||
else [result.isbn13]
|
||||
if result.isbn13
|
||||
else []
|
||||
)
|
||||
self.pages = str(result.pages) if result.pages else None
|
||||
self.publisher = result.publisher
|
||||
self.year = str(result.year) if result.year else None
|
||||
# self.pages = str(result.pages) if result.pages else None
|
||||
return self
|
||||
|
||||
@property
|
||||
def edition_number(self) -> Optional[int]:
|
||||
if self.edition is None:
|
||||
return 0
|
||||
match = regex.search(r"(\d+)", self.edition)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailData:
|
||||
subject: str | None = None
|
||||
body: str | None = None
|
||||
mailto: str | None = None
|
||||
prof: str | None = None
|
||||
subject: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
mailto: Optional[str] = None
|
||||
prof: Optional[str] = None
|
||||
|
||||
|
||||
class Subjects(Enum):
|
||||
@@ -131,18 +211,19 @@ class Subjects(Enum):
|
||||
ECONOMICS = (24, "Wirtschaftslehre")
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
def id(self) -> int:
|
||||
return self.value[0]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def name(self) -> str:
|
||||
return self.value[1]
|
||||
|
||||
@classmethod
|
||||
def get_index(cls, name):
|
||||
def get_index(cls, name: str) -> Optional[int]:
|
||||
for i in cls:
|
||||
if i.name == name:
|
||||
return i.id - 1
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -162,7 +243,7 @@ class Apparat:
|
||||
prof_id_adis: str | None = None
|
||||
konto: int | None = None
|
||||
|
||||
def from_tuple(self, data: tuple):
|
||||
def from_tuple(self, data: tuple[Any, ...]) -> "Apparat":
|
||||
self.id = data[0]
|
||||
self.name = data[1]
|
||||
self.prof_id = data[2]
|
||||
@@ -180,7 +261,7 @@ class Apparat:
|
||||
return self
|
||||
|
||||
@property
|
||||
def get_semester(self):
|
||||
def get_semester(self) -> Optional[str]:
|
||||
if self.extend_until is not None:
|
||||
return self.extend_until
|
||||
else:
|
||||
@@ -194,7 +275,7 @@ class ELSA:
|
||||
semester: str | None = None
|
||||
prof_id: int | None = None
|
||||
|
||||
def from_tuple(self, data):
|
||||
def from_tuple(self, data: tuple[Any, ...]) -> "ELSA":
|
||||
self.id = data[0]
|
||||
self.date = data[1]
|
||||
self.semester = data[2]
|
||||
@@ -206,3 +287,124 @@ class ELSA:
|
||||
class ApparatData:
|
||||
prof: Prof = field(default_factory=Prof)
|
||||
apparat: Apparat = field(default_factory=Apparat)
|
||||
|
||||
|
||||
@dataclass
|
||||
class XMLMailSubmission:
|
||||
name: Optional[str] = None
|
||||
lastname: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
telno: Optional[int] = None
|
||||
email: Optional[str] = None
|
||||
app_name: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
semester: Optional[Semester] = None
|
||||
books: Optional[list[BookData]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Book:
|
||||
author: str = None
|
||||
year: str = None
|
||||
edition: str = None
|
||||
title: str = None
|
||||
location: str = None
|
||||
publisher: str = None
|
||||
signature: str = None
|
||||
internal_notes: str = None
|
||||
|
||||
@property
|
||||
def has_signature(self) -> bool:
|
||||
return self.signature is not None and self.signature != ""
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return all(
|
||||
[
|
||||
self.author == "",
|
||||
self.year == "",
|
||||
self.edition == "",
|
||||
self.title == "",
|
||||
self.location == "",
|
||||
self.publisher == "",
|
||||
self.signature == "",
|
||||
self.internal_notes == "",
|
||||
]
|
||||
)
|
||||
|
||||
def from_dict(self, data: dict[str, Any]):
|
||||
for key, value in data.items():
|
||||
value = value.strip()
|
||||
if value == "\u2002\u2002\u2002\u2002\u2002":
|
||||
value = ""
|
||||
|
||||
if key == "Autorenname(n):Nachname, Vorname":
|
||||
self.author = value
|
||||
elif key == "Jahr/Auflage":
|
||||
self.year = value.split("/")[0] if "/" in value else value
|
||||
self.edition = value.split("/")[1] if "/" in value else ""
|
||||
elif key == "Titel":
|
||||
self.title = value
|
||||
elif key == "Ort und Verlag":
|
||||
self.location = value.split(",")[0] if "," in value else value
|
||||
self.publisher = value.split(",")[1] if "," in value else ""
|
||||
elif key == "Standnummer":
|
||||
self.signature = value.strip()
|
||||
elif key == "Interne Vermerke":
|
||||
self.internal_notes = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class SemapDocument:
|
||||
subject: str = None
|
||||
phoneNumber: int = None
|
||||
mail: str = None
|
||||
title: str = None
|
||||
title_suggestions: list[str] = None
|
||||
semester: Union[str, Semester] = None
|
||||
books: list[Book] = None
|
||||
eternal: bool = False
|
||||
personName: str = None
|
||||
personTitle: str = None
|
||||
title_length = 0
|
||||
title_max_length = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.title_suggestions = []
|
||||
|
||||
@property
|
||||
def nameSetter(self):
|
||||
data = name_tester(self.personTitle)
|
||||
name = f"{data['last_name']}, {data['first_name']}"
|
||||
if data["title"] is not None:
|
||||
title = data["title"]
|
||||
self.personTitle = title
|
||||
self.personName = name
|
||||
self.title_length = len(self.title) + 3 + len(self.personName.split(",")[0])
|
||||
if self.title_length > 40:
|
||||
name_len = len(self.personName.split(",")[0])
|
||||
self.title_max_length = 38 - name_len
|
||||
suggestions = run_shortener(self.title, self.title_max_length)
|
||||
for suggestion in suggestions:
|
||||
self.title_suggestions.append(suggestion["shortened_string"])
|
||||
else:
|
||||
self.title_suggestions = []
|
||||
pass
|
||||
|
||||
@property
|
||||
def renameSemester(self) -> None:
|
||||
if self.semester:
|
||||
if ", Dauer" in self.semester:
|
||||
self.semester = self.semester.split(",")[0]
|
||||
self.eternal = True
|
||||
self.semester = Semester().from_string(self.semester)
|
||||
else:
|
||||
self.semester = Semester().from_string(
|
||||
semester_converter(self.semester)
|
||||
)
|
||||
|
||||
@property
|
||||
def signatures(self) -> list[str]:
|
||||
if self.books is not None:
|
||||
return [book.signature for book in self.books if book.has_signature]
|
||||
return []
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from docx import Document
|
||||
|
||||
data = {}
|
||||
wordDoc = Document("files/Semesterapparat - Anmeldung.docx")
|
||||
paragraphs = wordDoc.tables
|
||||
for table in paragraphs:
|
||||
for column in table.columns:
|
||||
cellcount = 0
|
||||
for _cell in column.cells:
|
||||
if cellcount < 12:
|
||||
cellcount += 1
|
||||
# print(f"cell:{cell.text}")
|
||||
|
||||
# # print(f'paragraphs[{i}]: {paragraphs[i]}')
|
||||
# data[i] = paragraphs[i]
|
||||
|
||||
# for i in range(0, len(paragraphs)):
|
||||
# for i in range(2, len(paragraphs)):
|
||||
# data[i] = paragraphs[i]
|
||||
|
||||
# print(data)
|
||||
|
||||
# for table in wordDoc.tables:
|
||||
# for row in table.rows:
|
||||
# # print('---')
|
||||
# for cell in row.cells:
|
||||
# # print(f'cell:{cell.text}')
|
||||
@@ -1,10 +0,0 @@
|
||||
import tabula
|
||||
|
||||
file = "files/Semesterapparat - Anmeldung.pdf"
|
||||
|
||||
|
||||
def extract_book_data(file):
|
||||
tabula.read_pdf(file, pages="all", encoding="utf-8", multiple_tables=True)
|
||||
tabula.convert_into(file, file.replace(".pdf"), output_format="csv", pages="all")
|
||||
with open("files/Semesterapparat - Anmeldung.csv", "r") as f:
|
||||
f.read()
|
||||
312
src/logic/lehmannsapi.py
Normal file
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Iterable, List, Optional
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.logic.dataclass import BookData
|
||||
|
||||
BASE = "https://www.lehmanns.de"
|
||||
SEARCH_URL = "https://www.lehmanns.de/search/quick?mediatype_id=&q="
|
||||
|
||||
|
||||
@dataclass
|
||||
class LehmannsSearchResult:
|
||||
title: str
|
||||
url: str
|
||||
|
||||
# Core fields from the listing card
|
||||
year: Optional[int] = None
|
||||
edition: Optional[int] = None
|
||||
publisher: Optional[str] = None
|
||||
isbn13: Optional[str] = None
|
||||
|
||||
# Extras from the listing card
|
||||
description: Optional[str] = None
|
||||
authors: list[str] = field(default_factory=list)
|
||||
media_type: Optional[str] = None
|
||||
book_format: Optional[str] = None
|
||||
price_eur: Optional[float] = None
|
||||
currency: str = "EUR"
|
||||
image: Optional[str] = None
|
||||
|
||||
# From detail page:
|
||||
pages: Optional[str] = None # "<N> Seiten"
|
||||
buyable: bool = True # set in enrich_pages (detail page)
|
||||
unavailable_hint: Optional[str] = (
|
||||
None # e.g. "Titel ist leider vergriffen; keine Neuauflage"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class LehmannsClient:
|
||||
"""Scrapes quick-search results, then enriches (and filters) via product pages."""
|
||||
|
||||
def __init__(self, timeout: float = 20.0):
|
||||
self.client = httpx.Client(
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||
),
|
||||
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def close(self):
|
||||
self.client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
# ------------------- Search (listing) -------------------
|
||||
|
||||
def build_search_url(self, title: str) -> str:
|
||||
# spaces -> '+'
|
||||
return SEARCH_URL + quote_plus(title)
|
||||
|
||||
def search_by_title(
|
||||
self,
|
||||
title: str,
|
||||
limit: Optional[int] = None,
|
||||
strict: bool = False,
|
||||
only_latest: bool = True,
|
||||
) -> List[BookData]:
|
||||
"""
|
||||
Parse the listing page only (no availability check here).
|
||||
Use enrich_pages(...) afterwards to fetch detail pages, add 'pages',
|
||||
and drop unbuyable items.
|
||||
"""
|
||||
url = self.build_search_url(title=title)
|
||||
html = self._get(url)
|
||||
if not html:
|
||||
return []
|
||||
results = self._parse_results(html)
|
||||
self.enrich_pages(results)
|
||||
|
||||
results = [BookData().from_LehmannsSearchResult(r) for r in results]
|
||||
if strict:
|
||||
# filter results to only those with exact title match (case-insensitive)
|
||||
title_lower = title.lower()
|
||||
results = [r for r in results if r.title and r.title.lower() == title_lower]
|
||||
# results = [r for r in results if r.buyable]
|
||||
return results
|
||||
if limit is not None:
|
||||
results = results[: max(0, limit)]
|
||||
if only_latest and len(results) > 1:
|
||||
# keep only the latest edition (highest edition number)
|
||||
results.sort(key=lambda r: (r.edition_number or 0), reverse=True)
|
||||
results = [results[0]]
|
||||
return results
|
||||
|
||||
# ------------------- Detail enrichment & filtering -------------------
|
||||
|
||||
def enrich_pages(
|
||||
self, results: Iterable[LehmannsSearchResult], drop_unbuyable: bool = True
|
||||
) -> List[LehmannsSearchResult]:
|
||||
"""
|
||||
Fetch each result.url, extract:
|
||||
- pages: from <span class="book-meta meta-seiten" itemprop="numberOfPages">...</span>
|
||||
- availability: from <li class="availability-3">...</li>
|
||||
* if it contains "Titel ist leider vergriffen", mark buyable=False
|
||||
* if it also contains "keine Neuauflage", set unavailable_hint accordingly
|
||||
If drop_unbuyable=True, exclude non-buyable results from the returned list.
|
||||
"""
|
||||
enriched: List[LehmannsSearchResult] = []
|
||||
for r in results:
|
||||
try:
|
||||
html = self._get(r.url)
|
||||
if not html:
|
||||
# Can't verify; keep as-is when not dropping, else skip
|
||||
if not drop_unbuyable:
|
||||
enriched.append(r)
|
||||
continue
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser") # type: ignore
|
||||
|
||||
# Pages
|
||||
pages_node = soup.select_one( # type: ignore
|
||||
"span.book-meta.meta-seiten[itemprop='numberOfPages'], "
|
||||
"span.book-meta.meta-seiten[itemprop='numberofpages'], "
|
||||
".meta-seiten [itemprop='numberOfPages'], "
|
||||
".meta-seiten[itemprop='numberOfPages'], "
|
||||
".book-meta.meta-seiten"
|
||||
)
|
||||
if pages_node:
|
||||
text = pages_node.get_text(" ", strip=True)
|
||||
m = re.search(r"\d+", text)
|
||||
if m:
|
||||
r.pages = f"{m.group(0)} Seiten"
|
||||
|
||||
# Availability via li.availability-3
|
||||
avail_li = soup.select_one("li.availability-3") # type: ignore
|
||||
if avail_li:
|
||||
avail_text = " ".join(
|
||||
avail_li.get_text(" ", strip=True).split()
|
||||
).lower()
|
||||
if "titel ist leider vergriffen" in avail_text:
|
||||
r.buyable = False
|
||||
if "keine neuauflage" in avail_text:
|
||||
r.unavailable_hint = (
|
||||
"Titel ist leider vergriffen; keine Neuauflage"
|
||||
)
|
||||
else:
|
||||
r.unavailable_hint = "Titel ist leider vergriffen"
|
||||
|
||||
# Append or drop
|
||||
if (not drop_unbuyable) or r.buyable:
|
||||
enriched.append(r)
|
||||
|
||||
except Exception:
|
||||
# On any per-item error, keep the record if not dropping; else skip
|
||||
if not drop_unbuyable:
|
||||
enriched.append(r)
|
||||
continue
|
||||
|
||||
return enriched
|
||||
|
||||
# ------------------- Internals -------------------
|
||||
|
||||
def _get(self, url: str) -> Optional[str]:
|
||||
try:
|
||||
r = self.client.get(url)
|
||||
r.encoding = "utf-8"
|
||||
if r.status_code == 200 and "text/html" in (
|
||||
r.headers.get("content-type") or ""
|
||||
):
|
||||
return r.text
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _parse_results(self, html: str) -> List[LehmannsSearchResult]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
results: list[LehmannsSearchResult] = []
|
||||
|
||||
for block in soup.select("div.info-block"):
|
||||
a = block.select_one(".title a[href]")
|
||||
if not a:
|
||||
continue
|
||||
url = urljoin(BASE, a["href"].strip())
|
||||
base_title = (block.select_one(".title [itemprop='name']") or a).get_text( # type: ignore
|
||||
strip=True
|
||||
)
|
||||
|
||||
# Alternative headline => extend title
|
||||
alt_tag = block.select_one(".description[itemprop='alternativeHeadline']") # type: ignore
|
||||
alternative_headline = alt_tag.get_text(strip=True) if alt_tag else None
|
||||
title = (
|
||||
f"{base_title} : {alternative_headline}"
|
||||
if alternative_headline
|
||||
else base_title
|
||||
)
|
||||
description = alternative_headline
|
||||
|
||||
# Authors from .author
|
||||
authors: list[str] = []
|
||||
author_div = block.select_one("div.author") # type: ignore
|
||||
if author_div:
|
||||
t = author_div.get_text(" ", strip=True)
|
||||
t = re.sub(r"^\s*von\s+", "", t, flags=re.I)
|
||||
for part in re.split(r"\s*;\s*|\s*&\s*|\s+und\s+", t):
|
||||
name = " ".join(part.split())
|
||||
if name:
|
||||
authors.append(name)
|
||||
|
||||
# Media + format
|
||||
media_type = None
|
||||
book_format = None
|
||||
type_text = block.select_one(".type") # type: ignore
|
||||
if type_text:
|
||||
t = type_text.get_text(" ", strip=True)
|
||||
m = re.search(r"\b(Buch|eBook|Hörbuch)\b", t)
|
||||
if m:
|
||||
media_type = m.group(1)
|
||||
fm = re.search(r"\(([^)]+)\)", t)
|
||||
if fm:
|
||||
book_format = fm.group(1).strip().upper()
|
||||
|
||||
# Year
|
||||
year = None
|
||||
y = block.select_one("[itemprop='copyrightYear']") # type: ignore
|
||||
if y:
|
||||
try:
|
||||
year = int(y.get_text(strip=True))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Edition
|
||||
edition = None
|
||||
ed = block.select_one("[itemprop='bookEdition']") # type: ignore
|
||||
if ed:
|
||||
m = re.search(r"\d+", ed.get_text(strip=True))
|
||||
if m:
|
||||
edition = int(m.group())
|
||||
|
||||
# Publisher
|
||||
publisher = None
|
||||
pub = block.select_one( # type: ignore
|
||||
".publisherprop [itemprop='name']"
|
||||
) or block.select_one(".publisher [itemprop='name']") # type: ignore
|
||||
if pub:
|
||||
publisher = pub.get_text(strip=True)
|
||||
|
||||
# ISBN-13
|
||||
isbn13 = None
|
||||
isbn_tag = block.select_one(".isbn [itemprop='isbn'], [itemprop='isbn']") # type: ignore
|
||||
if isbn_tag:
|
||||
digits = re.sub(r"[^0-9Xx]", "", isbn_tag.get_text(strip=True))
|
||||
m = re.search(r"(97[89]\d{10})", digits)
|
||||
if m:
|
||||
isbn13 = m.group(1)
|
||||
|
||||
# Price (best effort)
|
||||
price_eur = None
|
||||
txt = block.get_text(" ", strip=True)
|
||||
mprice = re.search(r"(\d{1,3}(?:\.\d{3})*,\d{2})\s*€", txt)
|
||||
if not mprice and block.parent:
|
||||
sib = block.parent.get_text(" ", strip=True)
|
||||
mprice = re.search(r"(\d{1,3}(?:\.\d{3})*,\d{2})\s*€", sib)
|
||||
if mprice:
|
||||
num = mprice.group(1).replace(".", "").replace(",", ".")
|
||||
try:
|
||||
price_eur = float(num)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Image (best-effort)
|
||||
image = None
|
||||
left_img = block.find_previous("img") # type: ignore
|
||||
if left_img and left_img.get("src"):
|
||||
image = urljoin(BASE, left_img["src"])
|
||||
|
||||
results.append(
|
||||
LehmannsSearchResult(
|
||||
title=title,
|
||||
url=url,
|
||||
description=description,
|
||||
authors=authors,
|
||||
media_type=media_type,
|
||||
book_format=book_format,
|
||||
year=year,
|
||||
edition=edition,
|
||||
publisher=publisher,
|
||||
isbn13=isbn13,
|
||||
price_eur=price_eur,
|
||||
image=image,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -1,92 +0,0 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
|
||||
from loguru import logger as log
|
||||
import sys
|
||||
|
||||
|
||||
if not os.path.exists("logs"):
|
||||
os.mkdir("logs")
|
||||
# open and close the file to create it
|
||||
logger = log
|
||||
logger.remove()
|
||||
logger.add("logs/application.log", rotation="50MB")
|
||||
# logger.add(sys.stderr, format="{time} {level} {message}", level="INFO")
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
colorize=True,
|
||||
format="<green>{time}</green> <level>{message}</level>",
|
||||
level="WARNING",
|
||||
)
|
||||
|
||||
|
||||
log_filesize = 10 * 1024**2 # 10MB
|
||||
backups = 5
|
||||
# Create a common file handler for all loggers
|
||||
common_file_handler = logging.handlers.RotatingFileHandler(
|
||||
"logs/application.log",
|
||||
mode="a",
|
||||
encoding="utf-8",
|
||||
maxBytes=log_filesize,
|
||||
backupCount=backups,
|
||||
)
|
||||
|
||||
common_file_handler.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
common_file_handler.setFormatter(formatter)
|
||||
|
||||
|
||||
# set max file size to 10MB, if exceeded, create a new file
|
||||
|
||||
|
||||
class MyLogger:
|
||||
def __init__(self, logger_name):
|
||||
self.logger = logging.getLogger(logger_name)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.logger.addHandler(common_file_handler)
|
||||
self.encoding = "utf-8"
|
||||
|
||||
def log_info(self, message: str):
|
||||
# ensure that the message is encoded in utf-8
|
||||
self.logger.info(message.encode(self.encoding))
|
||||
|
||||
def log_debug(self, message: str):
|
||||
self.logger.debug(message.encode(self.encoding))
|
||||
|
||||
def log_warning(self, message: str):
|
||||
self.logger.warning(message.encode(self.encoding))
|
||||
|
||||
def log_error(self, message: str):
|
||||
self.logger.error(message.encode(self.encoding))
|
||||
|
||||
def log_critical(self, message: str):
|
||||
self.logger.critical(message.encode(self.encoding))
|
||||
|
||||
def log_exception(self, message: str):
|
||||
self.logger.exception(message)
|
||||
|
||||
|
||||
# Usage example:
|
||||
if __name__ == "__main__":
|
||||
logger1 = MyLogger("Logger1")
|
||||
logger2 = MyLogger("Logger2")
|
||||
|
||||
logger1.log_info("This is an info message from Logger1")
|
||||
logger1.log_debug("This is a debug message from Logger1")
|
||||
logger1.log_warning("This is a warning message from Logger1")
|
||||
logger1.log_error("This is an error message from Logger1")
|
||||
logger1.log_critical("This is a critical message from Logger1")
|
||||
|
||||
logger2.log_info("This is an info message from Logger2")
|
||||
logger2.log_debug("This is a debug message from Logger2")
|
||||
logger2.log_warning("This is a warning message from Logger2")
|
||||
logger2.log_error("This is an error message from Logger2")
|
||||
logger2.log_critical("This is a critical message from Logger2")
|
||||
|
||||
try:
|
||||
# Simulate an exception
|
||||
raise Exception("An exception occurred")
|
||||
except Exception:
|
||||
logger1.log_exception("An exception occurred in Logger1")
|
||||
logger2.log_exception("An exception occurred in Logger2")
|
||||
58
src/logic/openai.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from src import settings
|
||||
|
||||
|
||||
def init_client() -> OpenAI:
|
||||
"""Initialize the OpenAI client with the API key and model from settings."""
|
||||
global client, model, api_key
|
||||
if not settings.openAI.api_key:
|
||||
raise ValueError("OpenAI API key is not set in the configuration.")
|
||||
if not settings.openAI.model:
|
||||
raise ValueError("OpenAI model is not set in the configuration.")
|
||||
|
||||
model = settings.openAI.model
|
||||
api_key = settings.openAI.api_key
|
||||
client = OpenAI(api_key=api_key)
|
||||
return client
|
||||
|
||||
|
||||
def run_shortener(title: str, length: int) -> list[dict[str, Any]]:
|
||||
client = init_client()
|
||||
response = client.responses.create( # type: ignore
|
||||
model=model,
|
||||
instructions="""you are a sentence shortener. The next message will contain the string to shorten and the length limit.
|
||||
You need to shorten the string to be under the length limit, while keeping as much detail as possible. The result may NOT be longer than the length limit.
|
||||
based on that, please reply only the shortened string. Give me 5 choices. if the length is too long, discard the string and try another one.Return the data as a python list containing the result as {"shortened_string": shortened_string, "length": lengthasInt}. Do not return the answer in a codeblock, use a pure string. Before answering, check the results and if ANY is longer than the needed_length, discard all and try again""",
|
||||
input=f'{{"string":"{title}", "needed_length":{length}}}',
|
||||
)
|
||||
answers = response.output_text
|
||||
return eval(answers) # type: ignore
|
||||
# answers are strings in json format, so we need to convert them to a list of dicts
|
||||
|
||||
|
||||
def name_tester(name: str) -> dict:
|
||||
client = init_client()
|
||||
response = client.responses.create( # type: ignore
|
||||
model=model,
|
||||
instructions="""you are a name tester, You are given a name and will have to split the name into first name, last name, and if present the title. Return the name in a json format with the keys "title", "first_name", "last_name". If no title is present, set title to none. Do NOt return the answer in a codeblock, use a pure json string. Assume the names are in the usual german naming scheme""",
|
||||
input=f'{{"name":"{name}"}}',
|
||||
)
|
||||
answers = response.output_text
|
||||
|
||||
return json.loads(answers)
|
||||
|
||||
|
||||
def semester_converter(semester: str) -> str:
|
||||
client = init_client()
|
||||
response = client.responses.create( # type: ignore
|
||||
model=model,
|
||||
instructions="""you are a semester converter. You will be given a string. Convert this into a string like this: SoSe YY or WiSe YY/YY+1. Do not return the answer in a codeblock, use a pure string.""",
|
||||
input=semester,
|
||||
)
|
||||
answers = response.output_text
|
||||
|
||||
return answers
|
||||
@@ -1,10 +1,9 @@
|
||||
# add depend path to system path
|
||||
|
||||
import pandas as pd
|
||||
from pdfquery import PDFQuery
|
||||
|
||||
|
||||
def pdf_to_csv(path: str) -> pd.DataFrame:
|
||||
def pdf_to_csv(path: str) -> str:
|
||||
"""
|
||||
Extracts the data from a pdf file and returns it as a pandas dataframe
|
||||
"""
|
||||
@@ -21,4 +20,4 @@ if __name__ == "__main__":
|
||||
text = pdf_to_csv("54_pdf.pdf")
|
||||
# remove linebreaks
|
||||
text = text.replace("\n", "")
|
||||
print(text)
|
||||
# print(text)
|
||||
|
||||
248
src/logic/semester.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Semester helper class
|
||||
|
||||
A small utility around the *German* academic calendar that distinguishes
|
||||
between *Wintersemester* (WiSe) and *Sommersemester* (SoSe).
|
||||
|
||||
Key points
|
||||
----------
|
||||
* A **`Semester`** is identified by a *term* ("SoSe" or "WiSe") and the last two
|
||||
digits of the calendar year in which the term *starts*.
|
||||
* Formatting **never** pads the year with a leading zero – so ``6`` stays ``6``.
|
||||
* ``offset(n)`` and the static ``generate_missing`` reliably walk the timeline
|
||||
one semester at a time with correct year transitions:
|
||||
|
||||
SoSe 6 → **WiSe 6/7** → SoSe 7 → WiSe 7/8 → …
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from src.shared.logging import log
|
||||
|
||||
|
||||
class Semester:
|
||||
"""Represents a German university semester (WiSe or SoSe)."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Class‑level defaults – will be *copied* to each instance and then
|
||||
# potentially overwritten in ``__init__``.
|
||||
# ------------------------------------------------------------------
|
||||
_year: int | None = int(str(datetime.datetime.now().year)[2:]) # 24 → 24
|
||||
_semester: str | None = None # "WiSe" or "SoSe" – set later
|
||||
_month: int | None = datetime.datetime.now().month
|
||||
value: str | None = None # Human‑readable label, e.g. "WiSe 23/24"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction helpers
|
||||
# ------------------------------------------------------------------
|
||||
def __init__(
|
||||
self,
|
||||
year: int | None = None,
|
||||
semester: str | None = None,
|
||||
month: int | None = None,
|
||||
) -> None:
|
||||
if year is not None:
|
||||
self._year = int(year)
|
||||
if semester is not None:
|
||||
if semester not in ("WiSe", "SoSe"):
|
||||
raise ValueError("semester must be 'WiSe' or 'SoSe'")
|
||||
self._semester = semester
|
||||
if month is not None:
|
||||
self._month = month
|
||||
|
||||
self.__post_init__()
|
||||
|
||||
def __post_init__(self) -> None: # noqa: D401 – keep original name
|
||||
if self._year is None:
|
||||
self._year = int(str(datetime.datetime.now().year)[2:])
|
||||
if self._month is None:
|
||||
self._month = datetime.datetime.now().month
|
||||
if self._semester is None:
|
||||
self._generate_semester_from_month()
|
||||
self._compute_value()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dunder helpers
|
||||
# ------------------------------------------------------------------
|
||||
def __str__(self) -> str: # noqa: D401 – keep original name
|
||||
return self.value or "<invalid Semester>"
|
||||
|
||||
def __repr__(self) -> str: # Helpful for debugging lists
|
||||
return f"Semester({self._year!r}, {self._semester!r})"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _generate_semester_from_month(self) -> None:
|
||||
"""Infer *WiSe* / *SoSe* from the month attribute."""
|
||||
self._semester = "WiSe" if (self._month <= 3 or self._month > 9) else "SoSe"
|
||||
|
||||
def _compute_value(self) -> None:
|
||||
"""Human‑readable semester label – e.g. ``WiSe 23/24`` or ``SoSe 24``."""
|
||||
year = self._year
|
||||
if self._semester == "WiSe":
|
||||
next_year = (year + 1) % 100 # wrap 99 → 0
|
||||
self.value = f"WiSe {year}/{next_year}"
|
||||
else: # SoSe
|
||||
self.value = f"SoSe {year}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
def offset(self, value: int) -> "Semester":
|
||||
"""Return a new :class:`Semester` *value* steps away.
|
||||
|
||||
The algorithm maps every semester to a monotonically increasing
|
||||
*linear index* so that simple addition suffices:
|
||||
|
||||
``index = year * 2 + (0 if SoSe else 1)``.
|
||||
"""
|
||||
if not isinstance(value, int):
|
||||
raise TypeError("value must be an int (number of semesters to jump)")
|
||||
if value == 0:
|
||||
return Semester(self._year, self._semester)
|
||||
|
||||
current_idx = self._year * 2 + (0 if self._semester == "SoSe" else 1)
|
||||
target_idx = current_idx + value
|
||||
if target_idx < 0:
|
||||
raise ValueError("offset would result in a negative year – not supported")
|
||||
|
||||
new_year, semester_bit = divmod(target_idx, 2)
|
||||
new_semester = "SoSe" if semester_bit == 0 else "WiSe"
|
||||
return Semester(new_year, new_semester)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Comparison helpers
|
||||
# ------------------------------------------------------------------
|
||||
def isPastSemester(self, current: "Semester") -> bool:
|
||||
log.debug(f"Comparing {self} < {current}")
|
||||
if self.year < current.year:
|
||||
return True
|
||||
if self.year == current.year:
|
||||
return (
|
||||
self.semester == "WiSe" and current.semester == "SoSe"
|
||||
) # WiSe before next SoSe
|
||||
return False
|
||||
|
||||
def isFutureSemester(self, current: "Semester") -> bool:
|
||||
if self.year > current.year:
|
||||
return True
|
||||
if self.year == current.year:
|
||||
return (
|
||||
self.semester == "SoSe" and current.semester == "WiSe"
|
||||
) # SoSe after WiSe of same year
|
||||
return False
|
||||
|
||||
def isMatch(self, other: "Semester") -> bool:
|
||||
return self.year == other.year and self.semester == other.semester
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convenience properties
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def next(self) -> "Semester":
|
||||
return self.offset(1)
|
||||
|
||||
@property
|
||||
def previous(self) -> "Semester":
|
||||
return self.offset(-1)
|
||||
|
||||
@property
|
||||
def year(self) -> int:
|
||||
return self._year
|
||||
|
||||
@property
|
||||
def semester(self) -> str:
|
||||
return self._semester
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Static helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def generate_missing(start: "Semester", end: "Semester") -> list[str]:
|
||||
"""Return all consecutive semesters from *start* to *end* (inclusive)."""
|
||||
if not isinstance(start, Semester) or not isinstance(end, Semester):
|
||||
raise TypeError("start and end must be Semester instances")
|
||||
if start.isFutureSemester(end) and not start.isMatch(end):
|
||||
raise ValueError("'start' must not be after 'end'")
|
||||
|
||||
chain: list[Semester] = [start.value]
|
||||
current = start
|
||||
while not current.isMatch(end):
|
||||
current = current.next
|
||||
chain.append(current.value)
|
||||
if len(chain) > 1000: # sanity guard
|
||||
raise RuntimeError("generate_missing exceeded sane iteration limit")
|
||||
return chain
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parsing helper
|
||||
# ------------------------------------------------------------------
|
||||
@classmethod
|
||||
def from_string(cls, s: str) -> "Semester":
|
||||
"""Parse a human‑readable semester label and return a :class:`Semester`.
|
||||
|
||||
Accepted formats (case‑insensitive)::
|
||||
|
||||
"SoSe <YY>" → SoSe of year YY
|
||||
"WiSe <YY>/<YY+1>" → Winter term starting in YY
|
||||
"WiSe <YY>" → Shorthand for the above (next year implied)
|
||||
|
||||
``YY`` may contain a leading zero ("06" → 6).
|
||||
"""
|
||||
if not isinstance(s, str):
|
||||
raise TypeError("s must be a string")
|
||||
|
||||
pattern = r"\s*(WiSe|SoSe)\s+(\d{1,2})(?:\s*/\s*(\d{1,2}))?\s*"
|
||||
m = re.fullmatch(pattern, s, flags=re.IGNORECASE)
|
||||
if not m:
|
||||
raise ValueError(
|
||||
"invalid semester string format – expected 'SoSe YY' or 'WiSe YY/YY' (spacing flexible)"
|
||||
)
|
||||
|
||||
term_raw, y1_str, y2_str = m.groups()
|
||||
term = term_raw.capitalize() # normalize case → "WiSe" or "SoSe"
|
||||
year = int(y1_str.lstrip("0") or "0") # "06" → 6, "0" stays 0
|
||||
|
||||
if term == "SoSe":
|
||||
if y2_str is not None:
|
||||
raise ValueError(
|
||||
"SoSe string should not contain '/' followed by a second year"
|
||||
)
|
||||
return cls(year, "SoSe")
|
||||
|
||||
# term == "WiSe"
|
||||
if y2_str is not None:
|
||||
next_year = int(y2_str.lstrip("0") or "0")
|
||||
expected_next = (year + 1) % 100
|
||||
if next_year != expected_next:
|
||||
raise ValueError("WiSe second year must equal first year + 1 (mod 100)")
|
||||
# Accept both explicit "WiSe 6/7" and shorthand "WiSe 6"
|
||||
return cls(year, "WiSe")
|
||||
|
||||
|
||||
# ------------------------- quick self‑test -------------------------
|
||||
if __name__ == "__main__":
|
||||
# Chain generation demo ------------------------------------------------
|
||||
s_start = Semester(6, "SoSe") # SoSe 6
|
||||
s_end = Semester(25, "WiSe") # WiSe 25/26
|
||||
chain = Semester.generate_missing(s_start, s_end)
|
||||
# print("generate_missing:", [str(s) for s in chain])
|
||||
|
||||
# Parsing demo ---------------------------------------------------------
|
||||
examples = [
|
||||
"SoSe 6",
|
||||
"WiSe 6/7",
|
||||
"WiSe 6",
|
||||
"SoSe 23",
|
||||
"WiSe 23/24",
|
||||
"WiSe 24",
|
||||
"WiSe 99/00",
|
||||
"SoSe 00",
|
||||
"WiSe 100/101", # test large year
|
||||
]
|
||||
for ex in examples:
|
||||
parsed = Semester.from_string(ex)
|
||||
print(f"'{ex}' → {parsed} ({parsed.year=}, {parsed.semester=})")
|
||||