fix stats calc
[wolnelektury.git] / src / social / models.py
1 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
3 #
4 from datetime import datetime
5 import uuid
6 from oauthlib.common import urlencode, generate_token
7 from random import randint
8 from django.db import models
9 from django.conf import settings
10 from django.contrib.auth.models import User
11 from django.core.exceptions import ValidationError
12 from django.core.mail import send_mail
13 from django.urls import reverse
14 from django.utils.timezone import now, utc
15 from catalogue.models import Book
16 from catalogue.utils import get_random_hash
17 from wolnelektury.utils import cached_render, clear_cached_renders
18 from .syncable import Syncable
19
20
21 class BannerGroup(models.Model):
22     name = models.CharField('nazwa', max_length=255, unique=True)
23     created_at = models.DateTimeField('utworzona', auto_now_add=True)
24
25     class Meta:
26         ordering = ('name',)
27         verbose_name = 'grupa bannerów'
28         verbose_name_plural = 'grupy bannerów'
29
30     def __str__(self):
31         return self.name
32
33     def get_absolute_url(self):
34         """This is used for testing."""
35         return "%s?banner_group=%d" % (reverse('main_page'), self.id)
36
37     def get_banner(self):
38         banners = self.cite_set.all()
39         count = banners.count()
40         if not count:
41             return None
42         return banners[randint(0, count-1)]
43
44
45 class Cite(models.Model):
46     book = models.ForeignKey(Book, models.CASCADE, verbose_name='książka', null=True, blank=True)
47     text = models.TextField('tekst', blank=True)
48     small = models.BooleanField(
49         'mały', default=False, help_text='Sprawia, że cytat wyświetla się mniejszym fontem.')
50     vip = models.CharField('VIP', max_length=128, null=True, blank=True)
51     link = models.URLField('odnośnik')
52     video = models.URLField('wideo', blank=True)
53     picture = models.ImageField('ilustracja', blank=True,
54             help_text='Najlepsze wymiary: 975 x 315 z tekstem, 487 x 315 bez tekstu.')
55     picture_alt = models.CharField('alternatywny tekst ilustracji', max_length=255, blank=True)
56     picture_title = models.CharField('tytuł ilustracji', max_length=255, null=True, blank=True)
57     picture_author = models.CharField('autor ilustracji', max_length=255, blank=True, null=True)
58     picture_link = models.URLField('link ilustracji', blank=True, null=True)
59     picture_license = models.CharField('nazwa licencji ilustracji', max_length=255, blank=True, null=True)
60     picture_license_link = models.URLField('adres licencji ilustracji', blank=True, null=True)
61
62     sticky = models.BooleanField('przyklejony', default=False, db_index=True,
63                                  help_text='Przyklejone cytaty mają pierwszeństwo.')
64     background_plain = models.BooleanField('jednobarwne tło', default=False)
65     background_color = models.CharField('kolor tła', max_length=32, blank=True)
66     image = models.ImageField(
67         'obraz tła', upload_to='social/cite', null=True, blank=True,
68         help_text='Najlepsze tło ma wielkość 975 x 315 px i waży poniżej 100kB.')
69     image_title = models.CharField('tytuł obrazu tła', max_length=255, null=True, blank=True)
70     image_author = models.CharField('autor obrazu tła', max_length=255, blank=True, null=True)
71     image_link = models.URLField('link obrazu tła', blank=True, null=True)
72     image_license = models.CharField('nazwa licencji obrazu tła', max_length=255, blank=True, null=True)
73     image_license_link = models.URLField('adres licencji obrazu tła', blank=True, null=True)
74
75     created_at = models.DateTimeField('utworzony', auto_now_add=True)
76     group = models.ForeignKey(BannerGroup, verbose_name='grupa', null=True, blank=True, on_delete=models.SET_NULL)
77
78     class Meta:
79         ordering = ('vip', 'text')
80         verbose_name = 'banner'
81         verbose_name_plural = 'bannery'
82
83     def __str__(self):
84         t = []
85         if self.text:
86             t.append(self.text[:60])
87         if self.book_id:
88             t.append('[ks.]'[:60])
89         t.append(self.link[:60])
90         if self.vip:
91             t.append('vip: ' + self.vip)
92         if self.picture:
93             t.append('[obr.]')
94         if self.video:
95             t.append('[vid.]')
96         return ', '.join(t)
97
98     def get_absolute_url(self):
99         """This is used for testing."""
100         return "%s?banner=%d" % (reverse('main_page'), self.id)
101
102     def has_box(self):
103         return self.video or self.picture
104
105     def has_body(self):
106         return self.vip or self.text or self.book
107
108     def layout(self):
109         pieces = []
110         if self.has_box():
111             pieces.append('box')
112         if self.has_body():
113             pieces.append('text')
114         return '-'.join(pieces)
115
116
117     def save(self, *args, **kwargs):
118         ret = super(Cite, self).save(*args, **kwargs)
119         self.clear_cache()
120         return ret
121
122     @cached_render('social/cite_promo.html')
123     def main_box(self):
124         return {
125             'cite': self,
126             'main': True,
127         }
128
129     def clear_cache(self):
130         clear_cached_renders(self.main_box)
131
132
133 class Carousel(models.Model):
134     placement = models.SlugField('miejsce', choices=[
135         ('main', 'main'),
136         ('main_2022', 'main 2022'),
137     ])
138     priority = models.SmallIntegerField('priorytet', default=0)
139     language = models.CharField('język', max_length=2, blank=True, default='', choices=settings.LANGUAGES)
140
141     class Meta:
142 #        ordering = ('placement', '-priority')
143         verbose_name = 'karuzela'
144         verbose_name_plural = 'karuzele'
145
146     def __str__(self):
147         return self.placement
148
149     @classmethod
150     def get(cls, placement):
151         carousel = cls.objects.filter(placement=placement).order_by('-priority', '?').first()
152         if carousel is None:
153             carousel = cls.objects.create(placement=placement)
154         return carousel
155
156
157 class CarouselItem(models.Model):
158     order = models.PositiveSmallIntegerField('kolejność')
159     carousel = models.ForeignKey(Carousel, models.CASCADE, verbose_name='karuzela')
160     banner = models.ForeignKey(
161         Cite, models.CASCADE, null=True, blank=True, verbose_name='banner')
162     banner_group = models.ForeignKey(
163         BannerGroup, models.CASCADE, null=True, blank=True, verbose_name='grupa bannerów')
164
165     class Meta:
166         ordering = ('order',)
167         verbose_name = 'element karuzeli'
168         verbose_name_plural = 'elementy karuzeli'
169
170     def __str__(self):
171         return str(self.banner or self.banner_group)
172
173     def clean(self):
174         if not self.banner and not self.banner_group:
175             raise ValidationError('Proszę wskazać banner albo grupę bannerów.')
176         elif self.banner and self.banner_group:
177             raise ValidationError('Proszę wskazać banner albo grupę bannerów.')
178
179     def get_banner(self):
180         return self.banner or self.banner_group.get_banner()
181
182
183 class UserProfile(models.Model):
184     user = models.OneToOneField(User, models.CASCADE)
185     notifications = models.BooleanField(default=False)
186
187     @classmethod
188     def get_for(cls, user):
189         obj, created = cls.objects.get_or_create(user=user)
190         return obj
191
192
193 class UserConfirmation(models.Model):
194     user = models.ForeignKey(User, models.CASCADE)
195     created_at = models.DateTimeField(auto_now_add=True)
196     key = models.CharField(max_length=128, unique=True)
197
198     def send(self):
199         send_mail(
200             'Potwierdź konto w bibliotece Wolne Lektury',
201             f'https://beta.wolnelektury.pl/ludzie/potwierdz/{self.key}/',
202             settings.CONTACT_EMAIL,
203             [self.user.email]
204         )
205
206     def use(self):
207         user = self.user
208         user.is_active = True
209         user.save()
210         self.delete()
211     
212     @classmethod
213     def request(cls, user):
214         cls.objects.create(
215             user=user,
216             key=generate_token()
217         ).send()
218
219
220 class Progress(Syncable, models.Model):
221     user = models.ForeignKey(User, models.CASCADE)
222     book = models.ForeignKey('catalogue.Book', models.CASCADE)
223     created_at = models.DateTimeField(auto_now_add=True)
224     updated_at = models.DateTimeField(auto_now=True)
225     reported_timestamp = models.DateTimeField()
226     deleted = models.BooleanField(default=False)
227     last_mode = models.CharField(max_length=64, choices=[
228         ('text', 'text'),
229         ('audio', 'audio'),
230     ])
231     text_percent = models.FloatField(null=True, blank=True)
232     text_anchor = models.CharField(max_length=64, blank=True)
233     audio_percent = models.FloatField(null=True, blank=True)
234     audio_timestamp = models.FloatField(null=True, blank=True)
235     implicit_text_percent = models.FloatField(null=True, blank=True)
236     implicit_text_anchor = models.CharField(max_length=64, blank=True)
237     implicit_audio_percent = models.FloatField(null=True, blank=True)
238     implicit_audio_timestamp = models.FloatField(null=True, blank=True)
239
240     syncable_fields = [
241         'deleted',
242         'last_mode', 'text_anchor', 'audio_timestamp'
243     ]
244     
245     class Meta:
246         unique_together = [('user', 'book')]
247
248     @property
249     def timestamp(self):
250         return self.updated_at.timestamp()
251
252     @classmethod
253     def create_from_data(cls, user, data):
254         return cls.objects.create(
255             user=user,
256             book=data['book'],
257             reported_timestamp=now(),
258         )
259         
260     def save(self, *args, **kwargs):
261         try:
262             audio_l = self.book.get_audio_length()
263         except:
264             audio_l = 60
265         if self.text_anchor:
266             self.text_percent = 33
267             if audio_l:
268                 self.implicit_audio_percent = 40
269                 self.implicit_audio_timestamp = audio_l * .4
270         if self.audio_timestamp:
271             if self.audio_timestamp > audio_l:
272                 self.audio_timestamp = audio_l
273             if audio_l:
274                 self.audio_percent = 100 * self.audio_timestamp / audio_l
275                 self.implicit_text_percent = 60
276                 self.implicit_text_anchor = 'f20'
277         return super().save(*args, **kwargs)
278
279
280 class ActiveManager(models.Manager):
281     def get_queryset(self):
282         return super().get_queryset().filter(deleted=False)
283
284
285 class UserList(Syncable, models.Model):
286     slug = models.SlugField(unique=True)
287     user = models.ForeignKey(User, models.CASCADE)
288     name = models.CharField(max_length=1024)
289     favorites = models.BooleanField(default=False)
290     public = models.BooleanField(default=False)
291     deleted = models.BooleanField(default=False)
292     created_at = models.DateTimeField(auto_now_add=True)
293     updated_at = models.DateTimeField(auto_now=True)
294     reported_timestamp = models.DateTimeField()
295
296     syncable_fields = ['name', 'public', 'deleted']
297
298     objects = ActiveManager()
299     all_objects = models.Manager()
300
301     def get_absolute_url(self):
302         return reverse(
303             'tagged_object_list',
304             args=[f'polka/{self.slug}']
305         )
306
307     def __str__(self):
308         return self.name
309
310     @property
311     def url_chunk(self):
312         return f'polka/{self.slug}'
313     
314     @classmethod
315     def create_from_data(cls, user, data):
316         return cls.create(user, data['name'])
317
318     @classmethod
319     def create(cls, user, name):
320         n = now()
321         return cls.objects.create(
322             user=user,
323             name=name,
324             slug=get_random_hash(name),
325             updated_at=n,
326             reported_timestamp=n,
327         )
328
329     @classmethod
330     def get_by_name(cls, user, name, create=False):
331         l = cls.objects.filter(
332             user=user,
333             name=name
334         ).first()
335         if l is None and create:
336             l = cls.create(user, name)
337         return l
338         
339     @classmethod
340     def get_favorites_list(cls, user, create=False):
341         try:
342             return cls.objects.get(
343                 user=user,
344                 favorites=True
345             )
346         except cls.DoesNotExist:
347             n = now()
348             if create:
349                 return cls.objects.create(
350                     user=user,
351                     favorites=True,
352                     slug=get_random_hash('favorites'),
353                     updated_at=n,
354                     reported_timestamp=n,
355                 )
356             else:
357                 return None
358         except cls.MultipleObjectsReturned:
359             # merge?
360             lists = list(cls.objects.filter(user=user, favorites=True))
361             for l in lists[1:]:
362                 l.userlistitem_set.all().update(
363                     list=lists[0]
364                 )
365                 l.delete()
366             return lists[0]
367
368     @classmethod
369     def likes(cls, user, book):
370         ls = cls.get_favorites_list(user)
371         if ls is None:
372             return False
373         return ls.userlistitem_set.filter(deleted=False, book=book).exists()
374
375     def append(self, book):
376         n = now()
377         items = self.userlistitem_set.filter(
378             book=book,
379         )
380         if items.exists():
381             items.update(
382                 deleted=False,
383                 reported_timestamp=n,
384             )
385             item = items.first()
386         else:
387             item = self.userlistitem_set.create(
388                 book=book,
389                 order=(self.userlistitem_set.aggregate(m=models.Max('order'))['m'] or 0) + 1,
390                 updated_at=n,
391                 reported_timestamp=n,
392             )
393         book.update_popularity()
394         return item
395
396     def remove(self, book):
397         self.userlistitem_set.filter(book=book).update(
398             deleted=True,
399             updated_at=now()
400         )
401         book.update_popularity()
402
403     @classmethod
404     def like(cls, user, book):
405         ul = cls.get_favorites_list(user, create=True)
406         ul.append(book)
407
408     @classmethod
409     def unlike(cls, user, book):
410         ul = cls.get_favorites_list(user)
411         if ul is not None:
412             ul.remove(book)
413
414     def get_books(self):
415         return [item.book for item in self.userlistitem_set.exclude(deleted=True).exclude(book=None)]
416             
417
418 class UserListItem(Syncable, models.Model):
419     list = models.ForeignKey(UserList, models.CASCADE)
420     uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, blank=True)
421     order = models.IntegerField()
422     deleted = models.BooleanField(default=False)
423     created_at = models.DateTimeField(auto_now_add=True)
424     updated_at = models.DateTimeField(auto_now=True)
425     reported_timestamp = models.DateTimeField()
426
427     book = models.ForeignKey('catalogue.Book', models.SET_NULL, null=True, blank=True)
428     fragment = models.ForeignKey('catalogue.Fragment', models.SET_NULL, null=True, blank=True)
429     quote = models.ForeignKey('bookmarks.Quote', models.SET_NULL, null=True, blank=True)
430     bookmark = models.ForeignKey('bookmarks.Bookmark', models.SET_NULL, null=True, blank=True)
431
432     note = models.TextField(blank=True)
433     
434     syncable_fields = ['order', 'deleted', 'book', 'fragment', 'quote', 'bookmark', 'note']
435
436     @classmethod
437     def create_from_data(cls, user, data):
438         if data.get('favorites'):
439             l = UserList.get_favorites_list(user, create=True)
440         else:
441             l = data['list']
442             try:
443                 assert l.user == user
444             except AssertionError:
445                 return
446         return l.append(book=data['book'])
447
448     @property
449     def favorites(self):
450         return self.list.favorites