| 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}\""
|
|---|