Coverage for rfpy/model/meta.py: 100%
52 statements
« prev ^ index » next coverage.py v7.0.1, created at 2022-12-31 16:00 +0000
« prev ^ index » next coverage.py v7.0.1, created at 2022-12-31 16:00 +0000
1from unicodedata import normalize
2import mimetypes
3import pathlib
4import re
6from sqlalchemy import (
7 Column,
8 Integer,
9 Unicode,
10 event
11)
12from sqlalchemy.ext.declarative import declarative_base
13from sqlalchemy.orm import object_session, validates
16def human_friendly_bytes(size):
17 if not size or size == 0:
18 return '0 KB'
19 elif size < 1024:
20 return '1 KB'
21 elif size < 1024 * 1024:
22 return '%s KB' % int(size / 1024)
23 else:
24 return '%s MB' % int(size / 1024 / 1024)
27class Visitor: # pragma: no cover
29 '''
30 Base visitor classes enabling subclasses to implement
31 just the methods they need.
32 '''
34 def __init__(self):
35 self.result = None
37 def hello_section(self, sec):
38 pass
40 def goodbye_section(self, sec):
41 pass
43 def visit_question(self, question):
44 '''
45 Using NotImplemented because presence of this method is
46 used to determine whether or not to load questions
48 Therefore better to make it clear that this method defined in this
49 class is never called
50 '''
51 raise NotImplementedError()
53 def get_result(self):
54 return self.result
56 def finalise(self):
57 pass
60class RfpBase(object):
61 '''
62 @DynamicAttrs
63 '''
64 # by default only show id
65 public_attrs = ['id']
67 __table_args__ = (
68 {'mysql_engine': 'InnoDB', 'mysql_charset': 'utf8mb4'},
69 )
71 id = Column(Integer, primary_key=True)
73 def as_dict(self):
74 attrs = self.public_attrs
75 return {k: getattr(self, k, 'Not Provided') for k in attrs}
77 def __repr__(self):
78 return f'<{self.__class__.__name__} {self.id}>'
80 @property
81 def _instance_session(self):
82 return object_session(self)
85Base = declarative_base(cls=RfpBase)
88@event.listens_for(Base, 'instrument_class', propagate=True)
89def receive_before_mapper_configured(mapper, class_):
90 '''
91 Check that all classes use InnoDB and utf8mb4 charset. If not Foreign Key
92 relationships can fail, especially during schema migrations
93 '''
94 dialect_options = class_.__table__.dialect_options
95 if 'mysql' not in dialect_options:
96 raise ValueError('SQLAlchemy config error, no MySQL options for %s' % class_)
97 mysql_options = dialect_options['mysql']
98 if mysql_options['engine'] != 'InnoDB':
99 raise ValueError('SQLAlchemy config error, MySQL engine not InnoDB for %s' % class_)
100 if mysql_options['charset'] != 'utf8mb4':
101 raise ValueError('SQLAlchemy config error, MySQL charset not utf8mb4 for %s' % class_)
104class AttachmentMixin():
106 size_bytes = Column('size', Integer, default=0, nullable=False)
107 filename = Column(Unicode(255), nullable=False)
108 mimetype = Column(Unicode(100), nullable=False)
110 @validates('filename')
111 def _make_safe_filename(self, _attr_name, filename):
112 '''
113 Uploaded files can use trick filenames like '../../../rc.local'
114 to try to hack an operating system.
115 Filenames aren't used directly in this app, so shouldn't be a danger
116 but we clean up anyway.
117 '''
118 ascii_filename = (normalize('NFKD', filename)
119 .encode('ascii', 'ignore')
120 .decode('ascii'))
121 ascii_filename = pathlib.Path(ascii_filename).name
122 return re.sub(r'\s|/|\\', '_', ascii_filename)
124 def guess_set_mimetype(self, filename):
125 '''
126 Set the mimetype attribute for this file based on
127 the filename extension
128 '''
129 mtype, _enc = mimetypes.guess_type(filename)
130 self.mimetype = mtype
131 return mtype
133 @property
134 def size(self):
135 return human_friendly_bytes(self.size_bytes)
137 def __repr__(self) -> str:
138 return f'{self.__class__.__name__} - filename: {self.filename} size: {self.size}'