1 | from textwrap import shorten
|
---|
2 | from string import capwords
|
---|
3 | from django.db import models
|
---|
4 | from django.utils.text import slugify
|
---|
5 | from django.core.validators import MaxValueValidator, MinValueValidator, FileExtensionValidator
|
---|
6 | from django_group_by import GroupByMixin
|
---|
7 | from django.db.models.functions import Lower
|
---|
8 |
|
---|
9 | # --------------------------------
|
---|
10 |
|
---|
11 |
|
---|
12 | class NoUpperCaseCharField(models.CharField):
|
---|
13 |
|
---|
14 | def __init__(self, *args, **kwargs):
|
---|
15 | super(NoUpperCaseCharField, self).__init__(*args, **kwargs)
|
---|
16 |
|
---|
17 | def pre_save(self, model_instance, add):
|
---|
18 | value = getattr(model_instance, self.attname, None)
|
---|
19 | if value and str(value).isupper():
|
---|
20 | # value = capwords(str(value))
|
---|
21 | # Based on https://stackoverflow.com/a/42500863
|
---|
22 | value = ' '.join((w[:1].upper() + w[1:] if w not in ['for', 'and', 'in', 'against', 'to', 'on', 'the', 'of', 'für', 'und', 'der', 'die', 'das', 'über', 'von'] else w)
|
---|
23 | for w in str(value).lower().split(' '))
|
---|
24 | #value = value[0].upper + value[1:]
|
---|
25 | setattr(model_instance, self.attname, value)
|
---|
26 | return value
|
---|
27 | else:
|
---|
28 | return super(NoUpperCaseCharField, self).pre_save(model_instance, add)
|
---|
29 |
|
---|
30 | # --------------------------------
|
---|
31 |
|
---|
32 |
|
---|
33 | class AbstractCategory(models.Model):
|
---|
34 | name = models.CharField(blank=False, max_length=50)
|
---|
35 | description = models.TextField(blank=True)
|
---|
36 |
|
---|
37 | class Meta:
|
---|
38 | abstract = True
|
---|
39 | ordering = [Lower('name')]
|
---|
40 |
|
---|
41 | def __str__(self) -> str:
|
---|
42 | return f"{self.name}"
|
---|
43 |
|
---|
44 |
|
---|
45 | # TODO Alle Versuche, in diese Oberklasse auch noch die Category (und zusammen mit name auch __str__) reinzunehmen
|
---|
46 | # sind bislang gescheitert. Die name könnten zudem verschieden lang sein bei den verschiedenen Entities
|
---|
47 | class AbstractEntity(models.Model):
|
---|
48 | members = models.ManyToManyField('self', symmetrical=False, blank=True, related_name="memberships")
|
---|
49 | # level: int
|
---|
50 |
|
---|
51 | class Meta:
|
---|
52 | abstract = True
|
---|
53 |
|
---|
54 | # Returns members and it's sub-members alike, including self. Inspired by https://stackoverflow.com/questions/4725343/
|
---|
55 | # Sure this becomes quite slow in case of deep hierarchies. TODO Some prefetch or other Django tricks may help here
|
---|
56 | # TODO This is depth first search. Breadth first could enable a more relevant ordering of filter results.
|
---|
57 | # Having this, a level should be set for each member entity, see comments
|
---|
58 | # It used to use https://pypi.org/project/django-tree-queries/ which TreeNode has a parent and allows to call
|
---|
59 | # descendants(include_self=True) on all nodes to perform the same. But in in queries we need the children only.
|
---|
60 | # Other implementation like django-tree-queries, django-mptt (not supported anymore) or django-treebeard may support
|
---|
61 | # a more efficient implementation, but - apart from django-tree-queries - require much more effort in using it
|
---|
62 | def get_community(self): #(self, level: int = 0):
|
---|
63 | #self.level = level
|
---|
64 | result = {self}
|
---|
65 | for member in self.members.all():
|
---|
66 | result |= member.get_community() # (level+1)
|
---|
67 | return result
|
---|
68 |
|
---|
69 | def get_near_community(self, level: int):
|
---|
70 | result = {self}
|
---|
71 | if level > 0:
|
---|
72 | for member in self.members.all():
|
---|
73 | result |= member.get_near_community(level-1)
|
---|
74 | return result
|
---|
75 |
|
---|
76 | def get_community_level(self, level: int):
|
---|
77 | if level > 0:
|
---|
78 | if level == 1:
|
---|
79 | result = {member for member in self.members.all()}
|
---|
80 | else:
|
---|
81 | result = set()
|
---|
82 | for member in self.members.all():
|
---|
83 | result |= member.get_community_level(level-1)
|
---|
84 | else:
|
---|
85 | result = {self}
|
---|
86 | return result
|
---|
87 |
|
---|
88 | def has_members(self) -> bool:
|
---|
89 | return self.members.count() > 0
|
---|
90 |
|
---|
91 | def is_member(self) -> bool:
|
---|
92 | return self.memberships.count() > 0
|
---|
93 |
|
---|
94 | def get_members(self):
|
---|
95 | # TODO Beware, this issues an SQL call for each row in the table
|
---|
96 | return [member for member in self.members.all()]
|
---|
97 |
|
---|
98 | def get_memberships(self):
|
---|
99 | # TODO Beware, this issues an SQL call for each row in the table
|
---|
100 | return [membership for membership in self.memberships.all()]
|
---|
101 |
|
---|
102 | # Used for prettier display in admin
|
---|
103 | has_members.boolean = True
|
---|
104 | is_member.boolean = True
|
---|
105 | get_members.short_description = "Members"
|
---|
106 | get_memberships.short_description = "Memberships"
|
---|
107 |
|
---|
108 |
|
---|
109 | class ActorCategory(AbstractCategory):
|
---|
110 | class Meta(AbstractCategory.Meta):
|
---|
111 | verbose_name = "Actor Category"
|
---|
112 | verbose_name_plural = "Actor Categories"
|
---|
113 |
|
---|
114 |
|
---|
115 | class Actor(AbstractEntity):
|
---|
116 | name = models.CharField(blank=False, max_length=100)
|
---|
117 | abbreviation = models.CharField(blank=True, max_length=20)
|
---|
118 | alternative_names = models.CharField(blank=True, max_length=250)
|
---|
119 | category = models.ForeignKey(ActorCategory, null=True, on_delete=models.DO_NOTHING)
|
---|
120 |
|
---|
121 | class Meta:
|
---|
122 | ordering = [Lower('name')]
|
---|
123 | verbose_name = "Actor"
|
---|
124 | verbose_name_plural = "Actors"
|
---|
125 |
|
---|
126 | def __str__(self) -> str:
|
---|
127 | return f"{self.abbreviation if self.abbreviation else self.name}"
|
---|
128 |
|
---|
129 |
|
---|
130 | class ProductCategory(AbstractCategory):
|
---|
131 | class Meta(AbstractCategory.Meta):
|
---|
132 | verbose_name = "Product Category"
|
---|
133 | verbose_name_plural = "Product Categories"
|
---|
134 |
|
---|
135 |
|
---|
136 | class Product(AbstractEntity):
|
---|
137 | name = models.CharField(blank=False, max_length=100)
|
---|
138 | alternative_names = models.CharField(blank=True, max_length=250)
|
---|
139 | category = models.ForeignKey(ProductCategory, null=True, on_delete=models.DO_NOTHING)
|
---|
140 |
|
---|
141 | class Meta:
|
---|
142 | ordering = [Lower('name')]
|
---|
143 | verbose_name = "Product"
|
---|
144 | verbose_name_plural = "Products"
|
---|
145 |
|
---|
146 | def __str__(self) -> str:
|
---|
147 | return f"{self.name} ({self.category})"
|
---|
148 |
|
---|
149 |
|
---|
150 | class LocationCategory(AbstractCategory):
|
---|
151 | class Meta(AbstractCategory.Meta):
|
---|
152 | verbose_name = "Location Category"
|
---|
153 | verbose_name_plural = "Location Categories"
|
---|
154 |
|
---|
155 |
|
---|
156 | class Location(AbstractEntity):
|
---|
157 | name = models.CharField(blank=False, max_length=100)
|
---|
158 | category = models.ForeignKey(LocationCategory, null=True, on_delete=models.DO_NOTHING)
|
---|
159 |
|
---|
160 | class Meta:
|
---|
161 | ordering = [Lower('name')]
|
---|
162 | verbose_name = "Location"
|
---|
163 | verbose_name_plural = "Locations"
|
---|
164 |
|
---|
165 | def __str__(self) -> str:
|
---|
166 | return f"{self.name} ({self.category})"
|
---|
167 |
|
---|
168 |
|
---|
169 | class Impact(AbstractEntity):
|
---|
170 | name = models.CharField(blank=False, max_length=50)
|
---|
171 | alternative_names = models.CharField(blank=True, max_length=250)
|
---|
172 |
|
---|
173 | class Meta:
|
---|
174 | ordering = [Lower('name')]
|
---|
175 | verbose_name = "Impact"
|
---|
176 | verbose_name_plural = "Impacts"
|
---|
177 |
|
---|
178 | def __str__(self) -> str:
|
---|
179 | return f"{self.name}"
|
---|
180 |
|
---|
181 |
|
---|
182 | class Activity(AbstractEntity):
|
---|
183 | name = models.CharField(blank=False, max_length=50)
|
---|
184 | alternative_names = models.CharField(blank=True, max_length=250)
|
---|
185 |
|
---|
186 | class Meta:
|
---|
187 | ordering = [Lower('name')]
|
---|
188 | verbose_name = "Activity"
|
---|
189 | verbose_name_plural = "Activities"
|
---|
190 |
|
---|
191 | def __str__(self) -> str:
|
---|
192 | return f"{self.name}"
|
---|
193 |
|
---|
194 |
|
---|
195 | class Topic(models.Model):
|
---|
196 | products = models.ManyToManyField(Product, blank=True, related_name="topics")
|
---|
197 | locations = models.ManyToManyField(Location, blank=True, related_name="topics")
|
---|
198 | actors = models.ManyToManyField(Actor, blank=True, related_name="topics")
|
---|
199 | impacts = models.ManyToManyField(Impact, blank=True, related_name="topics")
|
---|
200 | activities = models.ManyToManyField(Activity, blank=True, related_name="topics")
|
---|
201 |
|
---|
202 | class Meta:
|
---|
203 | verbose_name = "Topic"
|
---|
204 | verbose_name_plural = "Topics"
|
---|
205 |
|
---|
206 | def __str__(self) -> str:
|
---|
207 | return f"{shorten(', '.join([str(p) for p in self.products.all()]), width=80, placeholder='...') or '-'} / " \
|
---|
208 | f"{shorten(', '.join([str(l) for l in self.locations.all()]), width=80, placeholder='...') or '-'} / " \
|
---|
209 | f"{shorten(', '.join([str(a) for a in self.actors.all()]), width=80, placeholder='...') or '-'} / " \
|
---|
210 | f"{shorten(', '.join([str(i) for i in self.impacts.all()]), width=80, placeholder='...') or '-'} / " \
|
---|
211 | f"{shorten(', '.join([str(a) for a in self.activities.all()]), width=80, placeholder='...') or '-'}"
|
---|
212 |
|
---|
213 |
|
---|
214 | def upload_file(instance, filename):
|
---|
215 | file_path = "documents/{0}/{1}/{2}.{3}".format(
|
---|
216 | instance.release_date.year if instance.release_date else "",
|
---|
217 | instance.release_date.month if (instance.release_date and
|
---|
218 | (instance.release_date.day != 1 or instance.release_date.month != 1)) else "",
|
---|
219 | slugify(instance.title), "pdf")
|
---|
220 | return file_path
|
---|
221 |
|
---|
222 |
|
---|
223 | class Document(models.Model):
|
---|
224 | title = NoUpperCaseCharField(blank=False, max_length=250)
|
---|
225 | subtitle = NoUpperCaseCharField(max_length=250, blank=True)
|
---|
226 | author = NoUpperCaseCharField(max_length=250, blank=True, verbose_name="Author(s)")
|
---|
227 | assocs = models.ManyToManyField(Actor, blank=True, verbose_name="Association")
|
---|
228 | release_date = models.DateField(blank=True, null=True)
|
---|
229 | volume = NoUpperCaseCharField(max_length=250, blank=True, help_text="Format-free reference to enclosing document")
|
---|
230 | source = models.URLField(blank=True, null=True)
|
---|
231 | abstract = models.TextField(blank=True)
|
---|
232 | file = models.FileField(blank=True, null=True, upload_to=upload_file, validators=[FileExtensionValidator(["pdf"])])
|
---|
233 | note = models.TextField(blank=True, help_text="Just some notes after reading the document")
|
---|
234 | todo = models.CharField(max_length=250, blank=True, help_text="Leftovers on tagging or reading this document")
|
---|
235 | topics = models.ManyToManyField(Topic, blank=True, through='Evidence', related_name="documents")
|
---|
236 | created_at = models.DateTimeField(auto_now_add=True)
|
---|
237 | updated_at = models.DateTimeField(auto_now=True)
|
---|
238 |
|
---|
239 | class Meta:
|
---|
240 | ordering = ["release_date"]
|
---|
241 | verbose_name = "Document"
|
---|
242 | verbose_name_plural = "Documents"
|
---|
243 |
|
---|
244 | def __str__(self) -> str:
|
---|
245 | return f"\"{self.title}\" by {','.join([str(a) for a in self.get_assocs()])}"
|
---|
246 |
|
---|
247 | def get_assocs(self):
|
---|
248 | # TODO Beware, this issues an SQL call for each row in the table
|
---|
249 | return [assoc for assoc in self.assocs.all()]
|
---|
250 |
|
---|
251 | get_assocs.short_description = "Associations"
|
---|
252 |
|
---|
253 |
|
---|
254 | class EvidenceQuerySet(models.QuerySet, GroupByMixin):
|
---|
255 | pass
|
---|
256 |
|
---|
257 |
|
---|
258 | class Evidence(models.Model):
|
---|
259 | document = models.ForeignKey(Document, on_delete=models.DO_NOTHING, related_name='evidences')
|
---|
260 | topic = models.ForeignKey(Topic, on_delete=models.DO_NOTHING, related_name='evidences')
|
---|
261 | reference = models.CharField(max_length=20, blank=True,
|
---|
262 | help_text="Section, chapter, page or similar reference within the document")
|
---|
263 | relevance = models.CharField(max_length=1, blank=True, choices=[
|
---|
264 | # Note that the keys (not the names) need to be in lexicographical order to help in sorting evidences
|
---|
265 | # TODO Replace with subclass of models.IntegerChoices
|
---|
266 | ("2", "High"),
|
---|
267 | ("5", "Useful"),
|
---|
268 | ("8", "Weak")
|
---|
269 | ])
|
---|
270 |
|
---|
271 | objects = EvidenceQuerySet.as_manager()
|
---|
272 |
|
---|
273 | class Meta:
|
---|
274 | ordering = ['relevance', '-document__release_date']
|
---|
275 | verbose_name = "Evidence"
|
---|
276 | verbose_name_plural = "Evidences" # Generally, there's no plural, but in this context...
|
---|
277 |
|
---|
278 | def __str__(self) -> str:
|
---|
279 | return f"{self.topic} in \"{self.document.title}\""
|
---|