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

1from unicodedata import normalize 

2from typing import Sequence 

3import mimetypes 

4import pathlib 

5import re 

6 

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) 

16 

17 

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) 

27 

28 

29class Visitor: # pragma: no cover 

30 """ 

31 Base visitor classes enabling subclasses to implement 

32 just the methods they need. 

33 """ 

34 

35 def __init__(self): 

36 self.result = None 

37 

38 def hello_section(self, sec): 

39 pass 

40 

41 def goodbye_section(self, sec): 

42 pass 

43 

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 

48 

49 Therefore better to make it clear that this method defined in this 

50 class is never called 

51 """ 

52 raise NotImplementedError() 

53 

54 def get_result(self): 

55 return self.result 

56 

57 def finalise(self): 

58 pass 

59 

60 

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} 

68 

69 

70class Base(DeclarativeBase): 

71 metadata = MetaData(naming_convention=naming_dict) 

72 

73 # by default only show id 

74 public_attrs: Sequence = ["id"] 

75 

76 id: Mapped[int] = mapped_column(Integer, primary_key=True) 

77 

78 def as_dict(self, *args, **kwargs): 

79 attrs = self.public_attrs 

80 return {k: getattr(self, k, "Not Provided") for k in attrs} 

81 

82 def __repr__(self): 

83 return f"<{self.__class__.__name__} {self.id}>" 

84 

85 @property 

86 def _instance_session(self): 

87 return object_session(self) 

88 

89 

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) 

94 

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) 

108 

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 

117 

118 @property 

119 def size(self): 

120 return human_friendly_bytes(self.size_bytes) 

121 

122 def __repr__(self) -> str: 

123 return ( 

124 f"{self.__class__.__name__} - filename: {self.filename} size: {self.size}" 

125 )