Allow publishing collections.
[wolnelektury.git] / src / club / models.py
index c49b888..1a78d76 100644 (file)
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
 from datetime import datetime, timedelta
 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 #
 from datetime import datetime, timedelta
+import os
+import tempfile
+from django.apps import apps
 from django.conf import settings
 from django.contrib.sites.models import Site
 from django.conf import settings
 from django.contrib.sites.models import Site
-from django.core.mail import send_mail
+from django.core.mail import send_mail, EmailMessage
 from django.urls import reverse
 from django.db import models
 from django import template
 from django.utils.timezone import now
 from django.urls import reverse
 from django.db import models
 from django import template
 from django.utils.timezone import now
-from django.utils.translation import ugettext_lazy as _, ungettext, ugettext, get_language
+from django.utils.translation import gettext_lazy as _, ngettext, gettext, get_language
+from django_countries.fields import CountryField
+from pytz import utc
 from catalogue.utils import get_random_hash
 from catalogue.utils import get_random_hash
-from .payment_methods import methods, method_by_slug
+from messaging.states import Level
+from reporting.utils import render_to_pdf
+from .payment_methods import methods
 from .payu import models as payu_models
 from .payu import models as payu_models
+from .civicrm import report_activity
+from . import utils
 
 
 
 
-class Plan(models.Model):
-    """ Plans are set up by administrators. """
-    MONTH = 30
-    YEAR = 365
-    PERPETUAL = 999
-    intervals = [
-        (MONTH, _('a month')),
-        (YEAR, _('a year')),
-        (PERPETUAL, _('in perpetuity')),
-    ]
-
-    interval = models.SmallIntegerField(_('inteval'), choices=intervals)
-    min_amount = models.DecimalField(_('min amount'), max_digits=10, decimal_places=2)
-    default_amount = models.DecimalField(_('default amount'), max_digits=10, decimal_places=2)
-    allow_recurring = models.BooleanField(_('allow recurring'))
-    allow_one_time = models.BooleanField(_('allow one time'))
-    active = models.BooleanField(_('active'), default=True)
+class Club(models.Model):
+    min_amount = models.IntegerField(_('minimum amount'))
+    min_for_year = models.IntegerField(_('minimum amount for year'))
+    default_single_amount = models.IntegerField(_('default single amount'))
+    default_monthly_amount = models.IntegerField(_('default monthly amount'))
 
     class Meta:
 
     class Meta:
-        verbose_name = _('plan')
-        verbose_name_plural = _('plans')
-
-    def __str__(self):
-        return "%s %s" % (self.min_amount, self.get_interval_display())
+        verbose_name = _('club')
+        verbose_name_plural = _('clubs')
     
     
+    def __str__(self):
+        return 'Klub'
+
+
+class SingleAmount(models.Model):
+    club = models.ForeignKey(Club, models.CASCADE)
+    amount = models.IntegerField()
+    description = models.TextField(blank=True)
+
     class Meta:
     class Meta:
-        ordering = ('interval',)
+        ordering = ['amount']
 
 
-    def payment_methods(self):
-        for method in methods:
-            if self.allow_recurring and method.is_recurring or self.allow_one_time and not method.is_recurring:
-                yield method
+class MonthlyAmount(models.Model):
+    club = models.ForeignKey(Club, models.CASCADE)
+    amount = models.IntegerField()
+    description = models.TextField(blank=True)
+
+    class Meta:
+        ordering = ['amount']
+
+
+class Consent(models.Model):
+    order = models.IntegerField()
+    active = models.BooleanField(default=True)
+    text = models.CharField(max_length=2048)
+    required = models.BooleanField()
+
+    class Meta:
+        ordering = ['order']
+    
+    def __str__(self):
+        return self.text
 
 
-    def get_next_installment(self, date):
-        if self.interval == self.PERPETUAL:
-            return datetime.max - timedelta(1)
-        elif self.interval == self.YEAR:
-            return date.replace(year=date.year + 1)
-        elif self.interval == self.MONTH:
-            day = date.day
-            date = (date.replace(day=1) + timedelta(31)).replace(day=1) + timedelta(day - 1)
-            if date.day != day:
-                date = date.replace(day=1)
-            return date
-            
 
 class Schedule(models.Model):
     """ Represents someone taking up a plan. """
     key = models.CharField(_('key'), max_length=255, unique=True)
     email = models.EmailField(_('email'))
 
 class Schedule(models.Model):
     """ Represents someone taking up a plan. """
     key = models.CharField(_('key'), max_length=255, unique=True)
     email = models.EmailField(_('email'))
-    membership = models.ForeignKey('Membership', verbose_name=_('membership'), null=True, blank=True, on_delete=models.PROTECT)
-    plan = models.ForeignKey(Plan, verbose_name=_('plan'), on_delete=models.PROTECT)
+    membership = models.ForeignKey('Membership', verbose_name=_('membership'), null=True, blank=True, on_delete=models.SET_NULL)
     amount = models.DecimalField(_('amount'), max_digits=10, decimal_places=2)
     amount = models.DecimalField(_('amount'), max_digits=10, decimal_places=2)
-    method = models.CharField(_('method'), max_length=255, choices=[(method.slug, method.name) for method in methods])
+    method = models.CharField(_('method'), max_length=32, choices=[
+        (m.slug, m.name) for m in methods
+        ])
+    monthly = models.BooleanField(_('monthly'), default=True)
+    yearly = models.BooleanField(_('yearly'), default=False)
+
+    source = models.CharField(_('source'), max_length=255, blank=True)
+
     is_cancelled = models.BooleanField(_('cancelled'), default=False)
     is_cancelled = models.BooleanField(_('cancelled'), default=False)
+    payed_at = models.DateTimeField(_('payed at'), null=True, blank=True)
     started_at = models.DateTimeField(_('started at'), auto_now_add=True)
     expires_at = models.DateTimeField(_('expires_at'), null=True, blank=True)
     email_sent = models.BooleanField(default=False)
 
     started_at = models.DateTimeField(_('started at'), auto_now_add=True)
     expires_at = models.DateTimeField(_('expires_at'), null=True, blank=True)
     email_sent = models.BooleanField(default=False)
 
+    first_name = models.CharField(max_length=255, blank=True)
+    last_name = models.CharField(max_length=255, blank=True)
+    phone = models.CharField(max_length=255, blank=True)
+    postal = models.CharField(max_length=255, blank=True)
+    postal_code = models.CharField(max_length=255, blank=True)
+    postal_town = models.CharField(max_length=255, blank=True)
+    postal_country = CountryField(default='PL', blank=True)
+
+    consent = models.ManyToManyField(Consent)
+    
     class Meta:
         verbose_name = _('schedule')
         verbose_name_plural = _('schedules')
     class Meta:
         verbose_name = _('schedule')
         verbose_name_plural = _('schedules')
@@ -84,7 +108,8 @@ class Schedule(models.Model):
     def save(self, *args, **kwargs):
         if not self.key:
             self.key = get_random_hash(self.email)
     def save(self, *args, **kwargs):
         if not self.key:
             self.key = get_random_hash(self.email)
-        return super(Schedule, self).save(*args, **kwargs)
+        super(Schedule, self).save(*args, **kwargs)
+        self.update_contact()
 
     def initiate_payment(self, request):
         return self.get_payment_method().initiate(request, self)
 
     def initiate_payment(self, request):
         return self.get_payment_method().initiate(request, self)
@@ -99,14 +124,59 @@ class Schedule(models.Model):
         return reverse('club_thanks', args=[self.key])
 
     def get_payment_method(self):
         return reverse('club_thanks', args=[self.key])
 
     def get_payment_method(self):
-        return method_by_slug[self.method]
+        return [m for m in methods if m.slug == self.method][0]
+
+    def get_payment_methods(self):
+        for method in methods:
+            if (self.monthly or self.yearly) and method.is_recurring:
+                yield method
+            elif not (self.monthly or self.yearly) and method.is_onetime:
+                yield method
 
     def is_expired(self):
         return self.expires_at is not None and self.expires_at <= now()
 
     def is_active(self):
 
     def is_expired(self):
         return self.expires_at is not None and self.expires_at <= now()
 
     def is_active(self):
-        return self.expires_at is not None and self.expires_at > now()
+        return self.payed_at is not None and (self.expires_at is None or self.expires_at > now())
 
 
+    def is_recurring(self):
+        return self.monthly or self.yearly
+
+    def set_payed(self):
+        since = self.expires_at
+        n = now()
+        if since is None or since < n:
+            since = n
+        new_exp = self.get_next_installment(since)
+        if self.payed_at is None:
+            self.payed_at = n
+        if self.expires_at is None or self.expires_at < new_exp:
+            self.expires_at = new_exp
+            self.save()
+
+        if not self.email_sent:
+            self.send_email()
+    
+    def get_next_installment(self, date):
+        if self.yearly:
+            return utils.add_year(date)
+        if self.monthly:
+            return utils.add_month(date)
+        club = Club.objects.first()
+        if club is not None and self.amount >= club.min_for_year:
+            return utils.add_year(date)
+        return utils.add_month(date)
+
+    def get_other_active_recurring(self):
+        schedules = type(self).objects.exclude(
+            monthly=False, yearly=False
+        ).filter(is_cancelled=False, expires_at__gt=now()).exclude(pk=self.pk)
+        mine_q = models.Q(email=self.email)
+        if self.membership is not None:
+            mine_q |= models.Q(membership__user=self.membership.user)
+        schedules = schedules.filter(mine_q)
+        return schedules.order_by('-expires_at').first()
+    
     def send_email(self):
         ctx = {'schedule': self}
         send_mail(
     def send_email(self):
         ctx = {'schedule': self}
         send_mail(
@@ -116,13 +186,42 @@ class Schedule(models.Model):
         self.email_sent = True
         self.save()
 
         self.email_sent = True
         self.save()
 
+    def send_email_failed_recurring(self):
+        ctx = {
+            'schedule': self,
+            'other': self.get_other_active_recurring(),
+        }
+        send_mail(
+            'Darowizna na Wolne Lektury — problem z płatnością',
+            template.loader.render_to_string('club/email/failed_recurring.txt', ctx),
+            settings.CONTACT_EMAIL, [self.email], fail_silently=False
+        )
+        
+    def update_contact(self):
+        Contact = apps.get_model('messaging', 'Contact')
+        if not self.payed_at:
+            level = Level.TRIED
+            since = self.started_at
+        else:
+            since = self.payed_at
+            if self.is_recurring():
+                level = Level.RECURRING
+            else:
+                level = Level.SINGLE
+        Contact.update(self.email, level, since, self.expires_at)
+
+
+
+
 
 class Membership(models.Model):
     """ Represents a user being recognized as a member of the club. """
     user = models.OneToOneField(settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
     created_at = models.DateTimeField(_('created at'), auto_now_add=True)
 
 class Membership(models.Model):
     """ Represents a user being recognized as a member of the club. """
     user = models.OneToOneField(settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
     created_at = models.DateTimeField(_('created at'), auto_now_add=True)
-    name = models.CharField(max_length=255, blank=True)
-    honorary = models.BooleanField(default=False)
+    name = models.CharField(_('name'), max_length=255, blank=True)
+    manual = models.BooleanField(_('manual'), default=False)
+    notes = models.CharField(_('notes'), max_length=2048, blank=True)
+    updated_at = models.DateField(_('updated at'), auto_now=True, blank=True)
 
     class Meta:
         verbose_name = _('membership')
 
     class Meta:
         verbose_name = _('membership')
@@ -131,6 +230,21 @@ class Membership(models.Model):
     def __str__(self):
         return str(self.user)
 
     def __str__(self):
         return str(self.user)
 
+    def save(self, *args, **kwargs):
+        super().save(*args, **kwargs)
+        self.update_contact()
+
+    def update_contact(self):
+        email = self.user.email
+        if not email:
+            return
+
+        Contact = apps.get_model('messaging', 'Contact')
+        if self.manual:
+            Contact.update(email, Level.MANUAL_MEMBER, datetime.combine(self.updated_at, datetime.min.time(), utc))
+        else:
+            Contact.reset(email)
+
     @classmethod
     def is_active_for(cls, user):
         if user.is_anonymous:
     @classmethod
     def is_active_for(cls, user):
         if user.is_anonymous:
@@ -139,7 +253,7 @@ class Membership(models.Model):
             membership = user.membership
         except cls.DoesNotExist:
             return False
             membership = user.membership
         except cls.DoesNotExist:
             return False
-        if membership.honorary:
+        if membership.manual:
             return True
         return Schedule.objects.filter(
                 expires_at__gt=now(),
             return True
         return Schedule.objects.filter(
                 expires_at__gt=now(),
@@ -164,6 +278,20 @@ class ReminderEmail(models.Model):
             return ungettext('a day after expiration', '%d days after expiration', n=-self.days_before)
 
 
             return ungettext('a day after expiration', '%d days after expiration', n=-self.days_before)
 
 
+class Ambassador(models.Model):
+    name = models.CharField(_('name'), max_length=255)
+    photo = models.ImageField(_('photo'), blank=True)
+    text = models.CharField(_('text'), max_length=1024)
+
+    class Meta:
+        verbose_name = _('ambassador')
+        verbose_name_plural = _('ambassadors')
+        ordering = ['name']
+    
+    def __str__(self):
+        return self.name
+
+        
 ########
 #      #
 # PayU #
 ########
 #      #
 # PayU #
@@ -182,13 +310,8 @@ class PayUOrder(payu_models.Order):
             "language": get_language(),
         }
 
             "language": get_language(),
         }
 
-    def get_continue_url(self):
-        return "https://{}{}".format(
-            Site.objects.get_current().domain,
-            self.schedule.get_thanks_url())
-
     def get_description(self):
     def get_description(self):
-        return ugettext('Towarzystwo Wolnych Lektur')
+        return 'Wolne Lektury'
 
     def is_recurring(self):
         return self.schedule.get_payment_method().is_recurring
 
     def is_recurring(self):
         return self.schedule.get_payment_method().is_recurring
@@ -201,16 +324,120 @@ class PayUOrder(payu_models.Order):
             Site.objects.get_current().domain,
             reverse('club_payu_notify', args=[self.pk]))
 
             Site.objects.get_current().domain,
             reverse('club_payu_notify', args=[self.pk]))
 
+    def get_thanks_url(self):
+        return self.schedule.get_thanks_url()
+
     def status_updated(self):
         if self.status == 'COMPLETED':
     def status_updated(self):
         if self.status == 'COMPLETED':
-            since = self.schedule.expires_at or now()
-            new_exp = self.schedule.plan.get_next_installment(since)
-            if self.schedule.expires_at is None or self.schedule.expires_at < new_exp:
-                self.schedule.expires_at = new_exp
-                self.schedule.save()
-
-            if not self.schedule.email_sent:
-                self.schedule.send_email()
+            self.schedule.set_payed()
+
+        elif self.status == 'CANCELED' or self.status.startswith('ERR-'):
+            if self.is_recurring() and self.schedule.expires_at:
+                self.schedule.send_email_failed_recurring()
+            
+        self.report_activity()
+
+    @property
+    def updated_at(self):
+        try:
+            return self.notification_set.all().order_by('-received_at')[0].received_at
+        except IndexError:
+            return None
+    
+    def report_activity(self):
+        if self.status not in ['COMPLETED', 'CANCELED', 'REJECTED']:
+            return
+
+        if self.status != 'COMPLETED':
+            name = settings.CIVICRM_ACTIVITIES['Failed contribution']
+        elif self.is_recurring():
+            name = settings.CIVICRM_ACTIVITIES['Recurring contribution']
+        else:
+            name = settings.CIVICRM_ACTIVITIES['Contribution']
+
+        report_activity.delay(
+            self.schedule.email,
+            self.schedule.key,
+            f'payu:{self.id}',
+            name,
+            self.updated_at,
+            {
+                'kwota': self.schedule.amount,
+            }
+        )            
+
+    @classmethod
+    def send_receipt(cls, email, year, resend=False):
+        Contact = apps.get_model('messaging', 'Contact')
+        Funding = apps.get_model('funding', 'Funding')
+        BillingAgreement = apps.get_model('paypal', 'BillingAgreement')
+        payments = []
+
+        try:
+            contact = Contact.objects.get(email=email)
+        except Contact.DoesNotExist:
+            funding = Funding.objects.filter(
+                email=email,
+                completed_at__year=year,
+                notifications=True).order_by('completed_at').first()
+            if funding is None:
+                print('no notifications')
+                return
+            optout = funding.wl_optout_url()
+        else:
+            if contact.level == Level.OPT_OUT:
+                print('opt-out')
+                return
+            optout = contact.wl_optout_url()
+
+        qs = cls.objects.filter(status='COMPLETED', schedule__email=email, completed_at__year=year).order_by('completed_at')
+        for order in qs:
+            payments.append({
+                'timestamp': order.completed_at,
+                'amount': order.get_amount(),
+            })
+
+        for ba in BillingAgreement.objects.filter(schedule__email=email):
+            payments.extend(ba.get_donations(year))
+
+        fundings = Funding.objects.filter(
+            email=email,
+            completed_at__year=year
+        ).order_by('completed_at')
+        for funding in fundings:
+            payments.append({
+                'timestamp': funding.completed_at,
+                'amount': funding.amount,
+            })
+
+        if not payments: return
+
+        payments.sort(key=lambda x: x['timestamp'])
+
+        ctx = {
+            "email": email,
+            "year": year,
+            "next_year": year + 1,
+            "total": sum(x['amount'] for x in payments),
+            "payments": payments,
+            "optout": optout,
+            "resend": resend,
+        }
+        temp = tempfile.NamedTemporaryFile(prefix='receipt-', suffix='.pdf', delete=False)
+        temp.close()
+        render_to_pdf(temp.name, 'club/receipt.texml', ctx, {
+            "fnp.eps": os.path.join(settings.STATIC_ROOT, "img/fnp.eps"),
+            })
+
+        message = EmailMessage(
+                'Odlicz darowiznę na Wolne Lektury od podatku',
+                template.loader.render_to_string('club/receipt_email.txt', ctx),
+                settings.CONTACT_EMAIL, [email]
+            )
+        with open(temp.name, 'rb') as f:
+            message.attach('wolnelektury-darowizny.pdf', f.read(), 'application/pdf')
+        message.send()
+        os.unlink(f.name)
 
 
 class PayUCardToken(payu_models.CardToken):
 
 
 class PayUCardToken(payu_models.CardToken):