Initialize clean CSR inventory repository
This commit is contained in:
267
inventory/import_export.py
Normal file
267
inventory/import_export.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO, StringIO
|
||||
from typing import Any, Iterable
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.cell import WriteOnlyCell
|
||||
|
||||
from .models import AuditLog, ChoiceValue, Device, Employee, NumberSequence
|
||||
from .device_types import clear_inapplicable
|
||||
from .services import add_audit
|
||||
|
||||
|
||||
COLUMN_MAP = {
|
||||
"nr.": "number", "nr": "number", "nummer": "number", "regal": "shelf", "status": "status",
|
||||
"datum": "date", "empfänger": "recipient", "empfaenger": "recipient", "ort": "location",
|
||||
"ticket": "ticket", "typ": "device_type", "preis": "price", "akku": "battery", "wlan": "wifi",
|
||||
"lan": "lan", "zoll": "display_inches", "ram": "ram_gb", "ssd": "ssd_gb", "hdd": "hdd_gb",
|
||||
"cpu": "cpu", "hwid": "hwid", "anschlüsse": "ports", "anschluesse": "ports",
|
||||
"auflösung": "resolution", "aufloesung": "resolution", "druckverfahren": "print_technology",
|
||||
"duplex": "duplex", "farbdruck": "color_printing",
|
||||
"modell": "model", "bearbeiter": "employee", "spender": "donor", "kommentar": "comment",
|
||||
}
|
||||
EXPORT_COLUMNS = [
|
||||
("number", "Nr."), ("shelf", "Regal"), ("status", "Status"), ("date", "Datum"),
|
||||
("recipient", "Empfänger"), ("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"),
|
||||
("price", "Preis"), ("battery", "Akku"), ("wifi", "WLAN"), ("lan", "LAN"),
|
||||
("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"), ("hdd_gb", "HDD"),
|
||||
("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"), ("resolution", "Auflösung"),
|
||||
("print_technology", "Druckverfahren"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"),
|
||||
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"),
|
||||
("comment", "Kommentar"),
|
||||
]
|
||||
|
||||
|
||||
def normalize_header(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def analyze_workbook(file_obj) -> tuple[list[str], list[list[str]]]:
|
||||
workbook = load_workbook(file_obj, read_only=True, data_only=True)
|
||||
sheet = workbook.active
|
||||
rows = sheet.iter_rows(values_only=True)
|
||||
headers = [str(value or "").strip() for value in next(rows, [])]
|
||||
preview = [[format_cell(value) for value in row[: len(headers)]] for _, row in zip(range(10), rows)]
|
||||
if not headers or not any(headers):
|
||||
raise ValueError("Die Datei enthält keine Kopfzeile.")
|
||||
return headers, preview
|
||||
|
||||
|
||||
def suggested_mapping(headers: list[str]) -> dict[int, str]:
|
||||
return {index: COLUMN_MAP.get(normalize_header(header), "") for index, header in enumerate(headers)}
|
||||
|
||||
|
||||
def format_cell(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def parse_date(value: Any) -> date:
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"Ungültiges Datum: {text}")
|
||||
|
||||
|
||||
def decimal_value(value: Any) -> Decimal | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
parsed = Decimal(str(value).replace("€", "").replace(" ", "").replace(",", "."))
|
||||
if not parsed.is_finite():
|
||||
raise InvalidOperation
|
||||
return parsed
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"Ungültige Zahl: {value}") from exc
|
||||
|
||||
|
||||
def integer_value(value: Any) -> int | None:
|
||||
decimal = decimal_value(value)
|
||||
if decimal is not None and decimal != decimal.to_integral_value():
|
||||
raise ValueError(f"Eine ganze Zahl ist erforderlich: {value}")
|
||||
return int(decimal) if decimal is not None else None
|
||||
|
||||
|
||||
def tri_state(value: Any) -> str:
|
||||
normalized = str(value or "").strip().upper()
|
||||
return {"JA": Device.TriState.YES, "YES": Device.TriState.YES, "NEIN": Device.TriState.NO, "NO": Device.TriState.NO}.get(normalized, Device.TriState.UNKNOWN)
|
||||
|
||||
|
||||
def resolve_choice(kind: str, value: Any, *, create: bool = True) -> ChoiceValue | None:
|
||||
label = str(value or "").strip()
|
||||
if not label:
|
||||
return None
|
||||
obj = ChoiceValue.objects.filter(kind=kind, label__iexact=label).first()
|
||||
if obj:
|
||||
return obj
|
||||
if not create:
|
||||
raise ValueError(f"Unbekannter Auswahlwert: {label}")
|
||||
return ChoiceValue.objects.create(kind=kind, label=label, active=True)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def import_workbook(file_obj, mapping: dict[int, str], user) -> dict[str, Any]:
|
||||
workbook = load_workbook(file_obj, read_only=True, data_only=True)
|
||||
sheet = workbook.active
|
||||
next(sheet.iter_rows(values_only=True), None)
|
||||
created = 0
|
||||
errors: list[str] = []
|
||||
maximum = 0
|
||||
for row_number, row in enumerate(sheet.iter_rows(values_only=True, min_row=2), start=2):
|
||||
if not any(value not in (None, "") for value in row):
|
||||
continue
|
||||
data = {field: row[index] if index < len(row) else None for index, field in mapping.items() if field}
|
||||
try:
|
||||
number = integer_value(data.get("number"))
|
||||
if number and Device.objects.filter(number=number).exists():
|
||||
raise ValueError(f"Gerätenummer {number} existiert bereits")
|
||||
status = resolve_choice(ChoiceValue.Kind.STATUS, data.get("status") or "Eingang")
|
||||
employee = None
|
||||
employee_code = str(data.get("employee") or "").strip()
|
||||
if employee_code:
|
||||
employee = Employee.objects.filter(code__iexact=employee_code).first()
|
||||
if not employee:
|
||||
raise ValueError(f"Unbekanntes Mitarbeiterkürzel: {employee_code}")
|
||||
device = Device(
|
||||
number=number or 0, shelf=str(data.get("shelf") or "").strip(), status=status,
|
||||
date=parse_date(data["date"]) if data.get("date") else date.today(),
|
||||
recipient=resolve_choice(ChoiceValue.Kind.RECIPIENT, data.get("recipient")),
|
||||
location=resolve_choice(ChoiceValue.Kind.LOCATION, data.get("location")),
|
||||
ticket=str(data.get("ticket") or "").strip(),
|
||||
device_type=resolve_choice(ChoiceValue.Kind.DEVICE_TYPE, data.get("device_type")),
|
||||
price=decimal_value(data.get("price")), battery=tri_state(data.get("battery")),
|
||||
wifi=tri_state(data.get("wifi")), lan=tri_state(data.get("lan")),
|
||||
display_inches=decimal_value(data.get("display_inches")), ram_gb=integer_value(data.get("ram_gb")),
|
||||
ssd_gb=integer_value(data.get("ssd_gb")), hdd_gb=integer_value(data.get("hdd_gb")),
|
||||
cpu=str(data.get("cpu") or "").strip(), hwid=str(data.get("hwid") or "").strip(),
|
||||
ports=str(data.get("ports") or "").strip(), resolution=str(data.get("resolution") or "").strip(),
|
||||
print_technology=str(data.get("print_technology") or "").strip(),
|
||||
duplex=tri_state(data.get("duplex")), color_printing=tri_state(data.get("color_printing")),
|
||||
model=str(data.get("model") or "").strip(),
|
||||
employee=employee, donor=str(data.get("donor") or "").strip(), comment=str(data.get("comment") or "").strip(),
|
||||
)
|
||||
clear_inapplicable(device)
|
||||
device.full_clean(exclude=["number", "custom_values"])
|
||||
device.save()
|
||||
maximum = max(maximum, device.number)
|
||||
add_audit(user, device, AuditLog.Action.IMPORT, new="Excel-Import")
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
errors.append(f"Zeile {row_number}: {exc}")
|
||||
if errors:
|
||||
raise ValueError("\n".join(errors[:50]))
|
||||
NumberSequence.ensure_at_least(maximum)
|
||||
return {"created": created, "errors": []}
|
||||
|
||||
|
||||
def export_value(device: Device, field: str) -> Any:
|
||||
value = getattr(device, field)
|
||||
if field in {"battery", "wifi", "lan", "duplex", "color_printing"}:
|
||||
return dict(Device.TriState.choices).get(value, value)
|
||||
if hasattr(value, "label"):
|
||||
return value.label
|
||||
if hasattr(value, "code"):
|
||||
return value.code
|
||||
return value if value is not None else ""
|
||||
|
||||
|
||||
def csv_safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str) and (value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n"))):
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def xlsx_safe_value(sheet, value: Any):
|
||||
if isinstance(value, str):
|
||||
cell = WriteOnlyCell(sheet, value=value)
|
||||
cell.data_type = "s"
|
||||
return cell
|
||||
return value
|
||||
|
||||
|
||||
def csv_response(devices: Iterable[Device]) -> HttpResponse:
|
||||
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||||
response["Content-Disposition"] = 'attachment; filename="geraete.csv"'
|
||||
response.write("\ufeff")
|
||||
writer = csv.writer(response, delimiter=";")
|
||||
writer.writerow([label for _, label in EXPORT_COLUMNS])
|
||||
for device in devices.iterator(chunk_size=1000):
|
||||
writer.writerow([csv_safe_value(export_value(device, field)) for field, _ in EXPORT_COLUMNS])
|
||||
return response
|
||||
|
||||
|
||||
def xlsx_response(devices: Iterable[Device]) -> HttpResponse:
|
||||
workbook = Workbook(write_only=True)
|
||||
sheet = workbook.create_sheet("Geräte")
|
||||
sheet.append([label for _, label in EXPORT_COLUMNS])
|
||||
for device in devices.iterator(chunk_size=1000):
|
||||
sheet.append([xlsx_safe_value(sheet, export_value(device, field)) for field, _ in EXPORT_COLUMNS])
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
response = HttpResponse(output.getvalue(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
response["Content-Disposition"] = 'attachment; filename="geraete.xlsx"'
|
||||
return response
|
||||
|
||||
|
||||
def _ods_cell(value: Any, *, header: bool = False) -> str:
|
||||
style = ' table:style-name="Kopfzeile"' if header else ""
|
||||
if isinstance(value, datetime):
|
||||
value = value.date()
|
||||
if isinstance(value, date):
|
||||
return f'<table:table-cell{style} office:value-type="date" office:date-value="{value.isoformat()}"><text:p>{value.strftime("%d.%m.%Y")}</text:p></table:table-cell>'
|
||||
if isinstance(value, (int, float, Decimal)) and not isinstance(value, bool):
|
||||
number = str(value)
|
||||
return f'<table:table-cell{style} office:value-type="float" office:value="{number}"><text:p>{escape(number)}</text:p></table:table-cell>'
|
||||
text = escape(str(value if value is not None else ""))
|
||||
return f'<table:table-cell{style} office:value-type="string"><text:p>{text}</text:p></table:table-cell>'
|
||||
|
||||
|
||||
def ods_response(devices: Iterable[Device]) -> HttpResponse:
|
||||
content = StringIO()
|
||||
content.write('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
content.write('<office:document-content office:version="1.2" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">')
|
||||
content.write('<office:automatic-styles><style:style style:name="Kopfzeile" style:family="table-cell"><style:text-properties fo:font-weight="bold"/></style:style></office:automatic-styles>')
|
||||
content.write('<office:body><office:spreadsheet><table:table table:name="Geräte">')
|
||||
content.write('<table:table-row>')
|
||||
for _, label in EXPORT_COLUMNS:
|
||||
content.write(_ods_cell(label, header=True))
|
||||
content.write('</table:table-row>')
|
||||
for device in devices.iterator(chunk_size=1000):
|
||||
content.write('<table:table-row>')
|
||||
for field, _ in EXPORT_COLUMNS:
|
||||
content.write(_ods_cell(export_value(device, field)))
|
||||
content.write('</table:table-row>')
|
||||
content.write('</table:table></office:spreadsheet></office:body></office:document-content>')
|
||||
|
||||
mime_type = "application/vnd.oasis.opendocument.spreadsheet"
|
||||
manifest = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest:manifest manifest:version="1.2" xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="{mime_type}"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||
</manifest:manifest>'''
|
||||
output = BytesIO()
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
archive.writestr("mimetype", mime_type, compress_type=zipfile.ZIP_STORED)
|
||||
archive.writestr("content.xml", content.getvalue(), compress_type=zipfile.ZIP_DEFLATED)
|
||||
archive.writestr("META-INF/manifest.xml", manifest, compress_type=zipfile.ZIP_DEFLATED)
|
||||
response = HttpResponse(output.getvalue(), content_type=mime_type)
|
||||
response["Content-Disposition"] = 'attachment; filename="geraete.ods"'
|
||||
return response
|
||||
Reference in New Issue
Block a user