quickfix: set User-Agent for wikidata
[redakcja.git] / src / catalogue / models.py
1 from collections import Counter
2 from datetime import date, timedelta
3 import decimal
4 import io
5 import re
6 from urllib.request import urlopen
7 from django.apps import apps
8 from django.conf import settings
9 from django.db import models
10 from django.template.loader import render_to_string
11 from django.urls import reverse
12 from django.utils.translation import gettext_lazy as _
13 from admin_ordering.models import OrderableModel
14 from librarian import DCNS
15 from librarian.cover import make_cover
16 from librarian.dcparser import BookInfo, Person
17 from .constants import WIKIDATA
18 from .wikidata import WikidataModel
19 from .wikimedia import WikiMedia
20
21
22 class Author(WikidataModel):
23     slug = models.SlugField(max_length=255, null=True, blank=True, unique=True)
24     first_name = models.CharField(_("first name"), max_length=255, blank=True)
25     last_name = models.CharField(_("last name"), max_length=255, blank=True)
26     genitive = models.CharField(
27         'dopełniacz', max_length=255, blank=True,
28         help_text='utwory … (czyje?)'
29     )
30
31     name_de = models.CharField(_("name (de)"), max_length=255, blank=True)
32     name_lt = models.CharField(_("name (lt)"), max_length=255, blank=True)
33
34     gender = models.CharField(_("gender"), max_length=255, blank=True)
35     nationality = models.CharField(_("nationality"), max_length=255, blank=True)
36
37     year_of_birth = models.SmallIntegerField(_("year of birth"), null=True, blank=True)
38     year_of_birth_inexact = models.BooleanField(_("inexact"), default=False)
39     year_of_birth_range = models.SmallIntegerField(_("year of birth, range end"), null=True, blank=True)
40     date_of_birth = models.DateField(_("date_of_birth"), null=True, blank=True)
41     century_of_birth = models.SmallIntegerField(
42         _("century of birth"), null=True, blank=True,
43         help_text=_('Set if year unknown. Negative for BC.')
44     )
45     place_of_birth = models.ForeignKey(
46         'Place', models.PROTECT, null=True, blank=True,
47         verbose_name=_('place of birth'),
48         related_name='authors_born'
49     )
50     year_of_death = models.SmallIntegerField(_("year of death"), null=True, blank=True)
51     year_of_death_inexact = models.BooleanField(_("inexact"), default=False)
52     year_of_death_range = models.SmallIntegerField(_("year of death, range end"), null=True, blank=True)
53     date_of_death = models.DateField(_("date_of_death"), null=True, blank=True)
54     century_of_death = models.SmallIntegerField(
55         _("century of death"), null=True, blank=True,
56         help_text=_('Set if year unknown. Negative for BC.')
57     )
58     place_of_death = models.ForeignKey(
59         'Place', models.PROTECT, null=True, blank=True,
60         verbose_name=_('place of death'),
61         related_name='authors_died'
62     )
63     status = models.PositiveSmallIntegerField(
64         _("status"), 
65         null=True,
66         blank=True,
67         choices=[
68             (1, _("Alive")),
69             (2, _("Dead")),
70             (3, _("Long dead")),
71             (4, _("Unknown")),
72         ],
73     )
74     notes = models.TextField(_("notes"), blank=True, help_text=_('private'))
75
76     gazeta_link = models.CharField(_("gazeta link"), max_length=255, blank=True)
77     culturepl_link = models.CharField(_("culture.pl link"), max_length=255, blank=True)
78     plwiki = models.CharField(blank=True, max_length=255)
79     photo = models.ImageField(blank=True, null=True, upload_to='catalogue/author/')
80     photo_source = models.CharField(blank=True, max_length=255)
81     photo_attribution = models.CharField(max_length=255, blank=True)
82
83     description = models.TextField(_("description"), blank=True, help_text=_('for publication'))
84
85     priority = models.PositiveSmallIntegerField(
86         _("priority"), 
87         default=0, choices=[(0, _("Low")), (1, _("Medium")), (2, _("High"))]
88     )
89     collections = models.ManyToManyField("Collection", blank=True, verbose_name=_("collections"))
90
91     woblink = models.IntegerField(null=True, blank=True)
92     
93     class Meta:
94         verbose_name = _('author')
95         verbose_name_plural = _('authors')
96         ordering = ("last_name", "first_name", "year_of_death")
97
98     class Wikidata:
99         first_name = WIKIDATA.GIVEN_NAME
100         last_name = WIKIDATA.LAST_NAME
101         date_of_birth = WIKIDATA.DATE_OF_BIRTH
102         year_of_birth = WIKIDATA.DATE_OF_BIRTH
103         place_of_birth = WIKIDATA.PLACE_OF_BIRTH
104         date_of_death = WIKIDATA.DATE_OF_DEATH
105         year_of_death = WIKIDATA.DATE_OF_DEATH
106         place_of_death = WIKIDATA.PLACE_OF_DEATH
107         gender = WIKIDATA.GENDER
108         notes = WikiMedia.append("description")
109         plwiki = "plwiki"
110         photo = WikiMedia.download(WIKIDATA.IMAGE)
111         photo_source = WikiMedia.descriptionurl(WIKIDATA.IMAGE)
112         photo_attribution = WikiMedia.attribution(WIKIDATA.IMAGE)
113
114         def _supplement(obj):
115             if not obj.first_name and not obj.last_name:
116                 yield 'first_name', 'label'
117
118     def __str__(self):
119         name = f"{self.first_name} {self.last_name}"
120         if self.year_of_death is not None:
121             name += f' (zm. {self.year_of_death})'
122         return name
123
124     def get_absolute_url(self):
125         return reverse("catalogue_author", args=[self.slug])
126
127     @classmethod
128     def get_by_literal(cls, literal):
129         names = literal.split(',', 1)
130         names = [n.strip() for n in names]
131         if len(names) == 2:
132             return cls.objects.filter(last_name=names[0], first_name=names[1]).first()
133         else:
134             return cls.objects.filter(last_name_pl=names[0], first_name_pl='').first() or \
135                 cls.objects.filter(first_name_pl=names[0], last_name_pl='').first() or \
136                 cls.objects.filter(first_name_pl=literal, last_name_pl='').first() or \
137                 cls.objects.filter(first_name_pl=literal, last_name_pl=None).first()
138
139     @property
140     def name(self):
141         return f"{self.last_name}, {self.first_name}"
142     
143     @property
144     def pd_year(self):
145         if self.year_of_death:
146             return self.year_of_death + 71
147         elif self.year_of_death == 0:
148             return 0
149         else:
150             return None
151
152     def generate_description(self):
153         t = render_to_string(
154             'catalogue/author_description.html',
155             {'obj': self}
156         )
157         return t
158
159     def century_description(self, number):
160         n = abs(number)
161         letters = ''
162         while n > 10:
163             letters += 'X'
164             n -= 10
165         if n == 9:
166             letters += 'IX'
167             n = 0
168         elif n >= 5:
169             letters += 'V'
170             n -= 5
171         if n == 4:
172             letters += 'IV'
173             n = 0
174         letters += 'I' * n
175         letters += ' w.'
176         if number < 0:
177             letters += ' p.n.e.'
178         return letters
179
180     def birth_century_description(self):
181         return self.century_description(self.century_of_birth)
182
183     def death_century_description(self):
184         return self.century_description(self.century_of_death)
185
186     def year_description(self, number):
187         n = abs(number)
188         letters = str(n)
189         letters += ' r.'
190         if number < 0:
191             letters += ' p.n.e.'
192         return letters
193
194     def year_of_birth_description(self):
195         return self.year_description(self.year_of_birth)
196     def year_of_death_description(self):
197         return self.year_description(self.year_of_death)
198
199
200 class NotableBook(OrderableModel):
201     author = models.ForeignKey(Author, models.CASCADE)
202     book = models.ForeignKey('Book', models.CASCADE)
203
204     def __str__(self):
205         return self.book.title
206
207
208 class Category(WikidataModel):
209     name = models.CharField(_("name"), max_length=255)
210     slug = models.SlugField(max_length=255, unique=True)
211     description = models.TextField(_("description"), blank=True, help_text=_('for publication'))
212
213     class Meta:
214         abstract = True
215
216     def __str__(self):
217         return self.name
218
219
220 class Epoch(Category):
221     adjective_feminine_singular = models.CharField(
222         'przymiotnik pojedynczy żeński', max_length=255, blank=True,
223         help_text='twórczość … Adama Mickiewicza'
224     )
225     adjective_nonmasculine_plural = models.CharField(
226         'przymiotnik mnogi niemęskoosobowy', max_length=255, blank=True,
227         help_text='utwory … Adama Mickiewicza'
228     )
229
230     class Meta:
231         verbose_name = _('epoch')
232         verbose_name_plural = _('epochs')
233
234
235 class Genre(Category):
236     thema = models.CharField(
237         max_length=32, blank=True,
238         help_text='Odpowiadający kwalifikator Thema.'
239     )
240     plural = models.CharField(
241         'liczba mnoga', max_length=255, blank=True,
242     )
243     is_epoch_specific = models.BooleanField(
244         default=False,
245         help_text='Po wskazaniu tego gatunku, dodanie epoki byłoby nadmiarowe, np. „dramat romantyczny”'
246     )
247
248     class Meta:
249         verbose_name = _('genre')
250         verbose_name_plural = _('genres')
251
252
253 class Kind(Category):
254     collective_noun = models.CharField(
255         'określenie zbiorowe', max_length=255, blank=True,
256         help_text='np. „Liryka” albo „Twórczość dramatyczna”'
257     )
258
259     class Meta:
260         verbose_name = _('kind')
261         verbose_name_plural = _('kinds')
262
263
264 class Book(WikidataModel):
265     slug = models.SlugField(max_length=255, blank=True, null=True, unique=True)
266     parent = models.ForeignKey('self', models.SET_NULL, null=True, blank=True)
267     parent_number = models.IntegerField(null=True, blank=True)
268     authors = models.ManyToManyField(Author, blank=True, verbose_name=_("authors"))
269     translators = models.ManyToManyField(
270         Author,
271         related_name="translated_book_set",
272         related_query_name="translated_book",
273         blank=True,
274         verbose_name=_("translators")
275     )
276     epochs = models.ManyToManyField(Epoch, blank=True, verbose_name=_("epochs"))
277     kinds = models.ManyToManyField(Kind, blank=True, verbose_name=_("kinds"))
278     genres = models.ManyToManyField(Genre, blank=True, verbose_name=_("genres"))
279     title = models.CharField(_("title"), max_length=255, blank=True)
280     language = models.CharField(_("language"), max_length=255, blank=True)
281     based_on = models.ForeignKey(
282         "self", models.PROTECT, related_name="translation", null=True, blank=True,
283         verbose_name=_("based on")
284     )
285     scans_source = models.CharField(_("scans source"), max_length=255, blank=True)
286     text_source = models.CharField(_("text source"), max_length=255, blank=True)
287     notes = models.TextField(_("notes"), blank=True, help_text=_('private'))
288     priority = models.PositiveSmallIntegerField(
289         _("priority"),
290         default=0, choices=[(0, _("Low")), (1, _("Medium")), (2, _("High"))]
291     )
292     original_year = models.IntegerField(_('original publication year'), null=True, blank=True)
293     pd_year = models.IntegerField(_('year of entry into PD'), null=True, blank=True)
294     plwiki = models.CharField(blank=True, max_length=255)
295     gazeta_link = models.CharField(_("gazeta link"), max_length=255, blank=True)
296     collections = models.ManyToManyField("Collection", blank=True, verbose_name=_("collections"))
297
298     estimated_chars = models.IntegerField(_("estimated number of characters"), null=True, blank=True)
299     estimated_verses = models.IntegerField(_("estimated number of verses"), null=True, blank=True)
300     estimate_source = models.CharField(_("source of estimates"), max_length=2048, blank=True)
301
302     free_license = models.BooleanField(_('free license'), default=False)
303     polona_missing = models.BooleanField(_('missing on Polona'), default=False)
304
305     cover = models.FileField(blank=True, upload_to='catalogue/cover')
306
307     monthly_views_reader = models.IntegerField(default=0)
308     monthly_views_page = models.IntegerField(default=0)
309     
310     class Meta:
311         ordering = ("title",)
312         verbose_name = _('book')
313         verbose_name_plural = _('books')
314
315     class Wikidata:
316         plwiki = "plwiki"
317         authors = WIKIDATA.AUTHOR
318         translators = WIKIDATA.TRANSLATOR
319         title = WIKIDATA.TITLE
320         language = WIKIDATA.LANGUAGE
321         based_on = WIKIDATA.BASED_ON
322         original_year = WIKIDATA.PUBLICATION_DATE
323         notes = WikiMedia.append("description")
324
325     def __str__(self):
326         txt = self.title
327         if self.original_year:
328             txt = f"{txt} ({self.original_year})"
329         astr = self.authors_str()
330         if astr:
331             txt = f"{txt}, {astr}"
332         tstr = self.translators_str()
333         if tstr:
334             txt = f"{txt}, tłum. {tstr}"
335         return txt
336
337     def build_cover(self):
338         width, height = 212, 300
339         # TODO: BookInfo shouldn't be required to build a cover.
340         info = BookInfo(rdf_attrs={}, dc_fields={
341             DCNS('creator'): [Person('Mickiewicz', 'Adam')],
342             DCNS('title'): ['Ziutek'],
343             DCNS('date'): ['1900-01-01'],
344             DCNS('publisher'): ['F'],
345             DCNS('language'): ['pol'],
346             DCNS('identifier.url'): ['test'],
347             DCNS('rights'): ['-'],
348         })
349         cover = make_cover(info, width=width, height=height)
350         out = io.BytesIO()
351         ext = cover.ext()
352         cover.save(out)
353         self.cover.save(f'{self.slug}.{ext}', out, save=False)
354         type(self).objects.filter(pk=self.pk).update(cover=self.cover)
355
356     def get_absolute_url(self):
357         return reverse("catalogue_book", args=[self.slug])
358
359     def is_text_public(self):
360         return self.free_license or (self.pd_year is not None and self.pd_year <= date.today().year)
361
362     def audio_status(self):
363         return {}
364     
365     @property
366     def wluri(self):
367         return f'https://wolnelektury.pl/katalog/lektura/{self.slug}/'
368     
369     def authors_str(self):
370         if not self.pk:
371             return ''
372         return ", ".join(str(author) for author in self.authors.all())
373     authors_str.admin_order_field = 'authors__last_name'
374     authors_str.short_description = _('Author')
375
376     def translators_str(self):
377         if not self.pk:
378             return ''
379         return ", ".join(str(author) for author in self.translators.all())
380     translators_str.admin_order_field = 'translators__last_name'
381     translators_str.short_description = _('Translator')
382
383     def authors_first_names(self):
384         return ', '.join(a.first_name for a in self.authors.all())
385
386     def authors_last_names(self):
387         return ', '.join(a.last_name for a in self.authors.all())
388
389     def translators_first_names(self):
390         return ', '.join(a.first_name for a in self.translators.all())
391
392     def translators_last_names(self):
393         return ', '.join(a.last_name for a in self.translators.all())
394
395     def document_book__project(self):
396         b = self.document_books.first()
397         if b is None: return ''
398         if b.project is None: return ''
399         return b.project.name
400
401     def audience(self):
402         try:
403             return self.document_books.first().wldocument().book_info.audience or ''
404         except:
405             return ''
406
407     def get_estimated_costs(self):
408         return {
409             work_type: work_type.calculate(self)
410             for work_type in WorkType.objects.all()
411         }
412
413     def scans_galleries(self):
414         return [bs.pk for bs in self.booksource_set.all()]
415
416     def is_published(self):
417         return any(b.is_published() for b in self.document_books.all())
418     
419     def update_monthly_stats(self):
420         # Find publication date.
421         # By default, get previous 12 months.
422         this_month = date.today().replace(day=1)
423         cutoff = this_month.replace(year=this_month.year - 1)
424         months = 12
425
426         # If the book was published later,
427         # find out the denominator.
428         pbr = apps.get_model('documents', 'BookPublishRecord').objects.filter(
429             book__catalogue_book=self).order_by('timestamp').first()
430         if pbr is not None and pbr.timestamp.date() > cutoff:
431             months = (this_month - pbr.timestamp.date()).days / 365 * 12
432
433         if not months:
434             return
435
436         stats = self.bookmonthlystats_set.filter(date__gte=cutoff).aggregate(
437             views_page=models.Sum('views_page'),
438             views_reader=models.Sum('views_reader')
439         )
440         self.monthly_views_page = stats['views_page'] / months
441         self.monthly_views_reader = stats['views_reader'] / months
442         self.save(update_fields=['monthly_views_page', 'monthly_views_reader'])
443
444     @property
445     def content_stats(self):
446         if hasattr(self, '_content_stats'):
447             return self._content_stats
448         try:
449             stats = self.document_books.first().wldocument(librarian2=True).get_statistics()['total']
450         except Exception as e:
451             stats = {}
452         self._content_stats = stats
453         return stats
454
455     @property
456     def are_sources_ready(self):
457         if not self.booksource_set.exists():
458             return False
459         for bs in self.booksource_set.all():
460             if not bs.source.has_view_files() or not bs.source.has_ocr_files() or bs.source.modified_at > bs.source.processed_at:
461                 return False
462         return True
463
464     chars = lambda self: self.content_stats.get('chars', '')
465     chars_with_fn = lambda self: self.content_stats.get('chars_with_fn', '')
466     words = lambda self: self.content_stats.get('words', '')
467     words_with_fn = lambda self: self.content_stats.get('words_with_fn', '')
468     verses = lambda self: self.content_stats.get('verses', '')
469     verses_with_fn = lambda self: self.content_stats.get('verses_with_fn', '')
470     chars_out_verse = lambda self: self.content_stats.get('chars_out_verse', '')
471     chars_out_verse_with_fn = lambda self: self.content_stats.get('chars_out_verse_with_fn', '')
472
473
474 class EditorNote(models.Model):
475     book = models.ForeignKey(Book, models.CASCADE)
476     user = models.ForeignKey(settings.AUTH_USER_MODEL, models.CASCADE)
477     created_at = models.DateTimeField(auto_now_add=True)
478     changed_at = models.DateTimeField(auto_now=True)
479     note = models.TextField(blank=True)
480     rate = models.IntegerField(default=3, choices=[
481         (n, n) for n in range(1, 6)
482     ])
483
484
485 class CollectionCategory(models.Model):
486     name = models.CharField(_("name"), max_length=255)
487     parent = models.ForeignKey('self', models.SET_NULL, related_name='children', null=True, blank=True, verbose_name=_("parent"))
488     notes = models.TextField(_("notes"), blank=True, help_text=_('private'))
489
490     class Meta:
491         ordering = ('parent__name', 'name')
492         verbose_name = _('collection category')
493         verbose_name_plural = _('collection categories')
494
495     def __str__(self):
496         if self.parent:
497             return f"{self.parent} / {self.name}"
498         else:
499             return self.name
500
501
502 class Collection(models.Model):
503     name = models.CharField(_("name"), max_length=255)
504     slug = models.SlugField(max_length=255, unique=True)
505     category = models.ForeignKey(CollectionCategory, models.SET_NULL, null=True, blank=True, verbose_name=_("category"))
506     notes = models.TextField(_("notes"), blank=True, help_text=_('private'))
507     description = models.TextField(_("description"), blank=True)
508
509     class Meta:
510         ordering = ('category', 'name')
511         verbose_name = _('collection')
512         verbose_name_plural = _('collections')
513
514     def __str__(self):
515         if self.category:
516             return f"{self.category} / {self.name}"
517         else:
518             return self.name
519
520     def get_estimated_costs(self):
521         costs = Counter()
522         for book in self.book_set.all():
523             for k, v in book.get_estimated_costs().items():
524                 costs[k] += v or 0
525
526         for author in self.author_set.all():
527             for book in author.book_set.all():
528                 for k, v in book.get_estimated_costs().items():
529                     costs[k] += v or 0
530             for book in author.translated_book_set.all():
531                 for k, v in book.get_estimated_costs().items():
532                     costs[k] += v or 0
533         return costs
534
535
536 class WorkType(models.Model):
537     name = models.CharField(_("name"), max_length=255)
538
539     class Meta:
540         ordering = ('name',)
541         verbose_name = _('work type')
542         verbose_name_plural = _('work types')
543     
544     def get_rate_for(self, book):
545         for workrate in self.workrate_set.all():
546             if workrate.matches(book):
547                 return workrate
548
549     def calculate(self, book):
550         workrate = self.get_rate_for(book)
551         if workrate is not None:
552             return workrate.calculate(book)
553         
554
555
556 class WorkRate(models.Model):
557     priority = models.IntegerField(_("priority"), default=1)
558     per_normpage = models.DecimalField(_("per normalized page"), decimal_places=2, max_digits=6, null=True, blank=True)
559     per_verse = models.DecimalField(_("per verse"), decimal_places=2, max_digits=6, null=True, blank=True)
560     work_type = models.ForeignKey(WorkType, models.CASCADE, verbose_name=_("work type"))
561     epochs = models.ManyToManyField(Epoch, blank=True, verbose_name=_("epochs"))
562     kinds = models.ManyToManyField(Kind, blank=True, verbose_name=_("kinds"))
563     genres = models.ManyToManyField(Genre, blank=True, verbose_name=_("genres"))
564     collections = models.ManyToManyField(Collection, blank=True, verbose_name=_("collections"))
565
566     class Meta:
567         ordering = ('priority',)
568         verbose_name = _('work rate')
569         verbose_name_plural = _('work rates')
570
571     def matches(self, book):
572         for category in 'epochs', 'kinds', 'genres', 'collections':
573             oneof = getattr(self, category).all()
574             if oneof:
575                 if not set(oneof).intersection(
576                         getattr(book, category).all()):
577                     return False
578         return True
579
580     def calculate(self, book):
581         if self.per_verse:
582             if book.estimated_verses:
583                 return book.estimated_verses * self.per_verse
584         elif self.per_normpage:
585             if book.estimated_chars:
586                 return (decimal.Decimal(book.estimated_chars) / 1800 * self.per_normpage).quantize(decimal.Decimal('1.00'), rounding=decimal.ROUND_HALF_UP)
587
588
589 class Place(WikidataModel):
590     name = models.CharField(_('name'), max_length=255, blank=True)
591     locative = models.CharField(_('locative'), max_length=255, blank=True, help_text=_('in…'))
592
593     class Meta:
594         verbose_name = _('place')
595         verbose_name_plural = _('places')
596     
597     class Wikidata:
598         name = 'label'
599
600     def __str__(self):
601         return self.name
602
603
604 class BookMonthlyStats(models.Model):
605     book = models.ForeignKey('catalogue.Book', models.CASCADE)
606     date = models.DateField()
607     views_reader = models.IntegerField(default=0)
608     views_page = models.IntegerField(default=0)
609
610     @classmethod
611     def build_for_month(cls, date):
612         date = date.replace(day=1)
613         period = 'month'
614
615         date = date.isoformat()
616         url = f'{settings.PIWIK_URL}?date={date}&filter_limit=-1&format=CSV&idSite={settings.PIWIK_WL_SITE_ID}&language=pl&method=Actions.getPageUrls&module=API&period={period}&segment=&token_auth={settings.PIWIK_TOKEN}&flat=1'
617         data = urlopen(url).read().decode('utf-16')
618         lines = data.split('\n')[1:]
619         for line in lines:
620             m = re.match('^/katalog/lektura/([^,./]+)\.html,', line)
621             if m is not None:
622                 which = 'views_reader'
623             else:
624                 m = re.match('^/katalog/lektura/([^,./]+)/,', line)
625                 if m is not None:
626                     which = 'views_page'
627             if m is not None:
628                 slug = m.group(1)
629                 _url, _uviews, views, _rest = line.split(',', 3)
630                 views = int(views)
631                 try:
632                     book = Book.objects.get(slug=slug)
633                 except Book.DoesNotExist:
634                     continue
635                 else:
636                     cls.objects.update_or_create(
637                         book=book, date=date,
638                         defaults={which: views}
639                     )
640                     book.update_monthly_stats()
641
642
643 class Thema(models.Model):
644     code = models.CharField(
645         max_length=128, unique=True,
646         help_text='Używamy rozszerzenia <code>.WL-</code> do oznaczania własnych kodów.<br> '
647         'Przykładowo, w przypadku potrzeby stworzenia nowej kategorii „insurekcja kościuszkowska”, '
648         'można by ją utworzyć jako 3MLQ‑PL‑A.WL-A, czyli w ramach już istniejącej wyższej kategorii 3MLQ‑PL‑A „rozbiory Polski”.',
649     )
650     name = models.CharField(max_length=1024)
651     slug = models.SlugField(
652         max_length=255, null=True, blank=True, unique=True,
653         help_text='Element adresu na WL, w postaci: /tag/slug/. Można zmieniać.'
654     )
655     plural = models.CharField(
656         'liczba mnoga', max_length=255, blank=True,
657     )
658     description = models.TextField(blank=True)
659     public_description = models.TextField(blank=True)
660     usable = models.BooleanField()
661     usable_as_main = models.BooleanField(default=False)
662     hidden = models.BooleanField(default=False)
663     woblink_category = models.IntegerField(null=True, blank=True)
664
665     class Meta:
666         ordering = ('code',)
667         verbose_name_plural = 'Thema'
668
669
670 class Audience(models.Model):
671     code = models.CharField(
672         max_length=128, unique=True,
673         help_text='Techniczny identifyikator. W miarę możliwości nie należy zmieniać.'
674     )
675     name = models.CharField(
676         max_length=1024,
677         help_text='W formie: „dla … (kogo?)”'
678     )
679     slug = models.SlugField(
680         max_length=255, null=True, blank=True, unique=True,
681         help_text='Element adresu na WL, w postaci: /dla/slug/. Można zmieniać.'
682     )
683     description = models.TextField(blank=True)
684     thema = models.CharField(
685         max_length=32, blank=True,
686         help_text='Odpowiadający kwalifikator Thema.'
687     )
688     woblink = models.IntegerField(null=True, blank=True)
689
690     class Meta:
691         ordering = ('code',)