Coverage for postrfp/model/meta.py: 100%
43 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1from unicodedata import normalize
2from typing import Sequence
3import mimetypes
4import pathlib
5import re
7from sqlalchemy import MetaData
8from sqlalchemy import Integer, Unicode
9from sqlalchemy.orm import (
10 object_session,
11 validates,
12 DeclarativeBase,
13 Mapped,
14 mapped_column,
15)
18def human_friendly_bytes(size):
19 if not size or size == 0:
20 return "0 KB"
21 elif size < 1024:
22 return "1 KB"
23 elif size < 1024 * 1024:
24 return "%s KB" % int(size / 1024)
25 else:
26 return "%s MB" % int(size / 1024 / 1024)
29class Visitor: # pragma: no cover
30 """
31 Base visitor classes enabling subclasses to implement
32 just the methods they need.
33 """
35 def __init__(self):
36 self.result = None
38 def hello_section(self, sec):
39 pass
41 def goodbye_section(self, sec):
42 pass
44 def visit_question(self, question):
45 """
46 Using NotImplemented because presence of this method is
47 used to determine whether or not to load questions
49 Therefore better to make it clear that this method defined in this
50 class is never called
51 """
52 raise NotImplementedError()
54 def get_result(self):
55 return self.result
57 def finalise(self):
58 pass
61naming_dict = {
62 "ix": "ix__%(table_name)s__%(column_0_N_name)s",
63 "uq": "uq__%(table_name)s__%(column_0_N_name)s",
64 "ck": "ck__%(table_name)s__%(constraint_name)s",
65 "fk": "fk__%(table_name)s__%(column_0_N_name)s__%(referred_table_name)s",
66 "pk": "pk__%(table_name)s",
67}
70class Base(DeclarativeBase):
71 metadata = MetaData(naming_convention=naming_dict)
73 # by default only show id
74 public_attrs: Sequence = ["id"]
76 id: Mapped[int] = mapped_column(Integer, primary_key=True)
78 def as_dict(self, *args, **kwargs):
79 attrs = self.public_attrs
80 return {k: getattr(self, k, "Not Provided") for k in attrs}
82 def __repr__(self):
83 return f"<{self.__class__.__name__} {self.id}>"
85 @property
86 def _instance_session(self):
87 return object_session(self)
90class AttachmentMixin:
91 size_bytes: Mapped[int] = mapped_column("size", Integer, default=0, nullable=False)
92 filename: Mapped[str] = mapped_column(Unicode(255), nullable=False)
93 mimetype: Mapped[str] = mapped_column(Unicode(100), nullable=False)
95 @validates("filename")
96 def _make_safe_filename(self, _attr_name, filename):
97 """
98 Uploaded files can use trick filenames like '../../../rc.local'
99 to try to hack an operating system.
100 Filenames aren't used directly in this app, so shouldn't be a danger
101 but we clean up anyway.
102 """
103 ascii_filename = (
104 normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii")
105 )
106 ascii_filename = pathlib.Path(ascii_filename).name
107 return re.sub(r"\s|/|\\", "_", ascii_filename)
109 def guess_set_mimetype(self, filename):
110 """
111 Set the mimetype attribute for this file based on
112 the filename extension
113 """
114 mtype, _enc = mimetypes.guess_type(filename)
115 self.mimetype = mtype
116 return mtype
118 @property
119 def size(self):
120 return human_friendly_bytes(self.size_bytes)
122 def __repr__(self) -> str:
123 return (
124 f"{self.__class__.__name__} - filename: {self.filename} size: {self.size}"
125 )