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