svthalia/concrexit

View on GitHub
website/utils/management/commands/createfixtures.py

Summary

Maintainability
A
1 hr
Test Coverage
"""Provides the command to generate fixtures."""

import math
import random
import string
from datetime import date, datetime, timedelta
from decimal import Decimal
from secrets import token_hex

from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management.base import BaseCommand, CommandError
from django.db.utils import IntegrityError
from django.utils import timezone
from django.utils.text import slugify

import factory
from faker import Faker
from pydenticon import Generator as IconGenerator

from activemembers.models import (
    Board,
    Committee,
    MemberGroup,
    MemberGroupMembership,
    Society,
)
from documents.models import Document
from education.models import Category, Course
from events.models import (
    EVENT_CATEGORIES,
    Event,
    EventRegistration,
    registration_member_choices_limit,
)
from members.models import Member, Membership, Profile
from newsletters.models import Newsletter, NewsletterEvent, NewsletterItem
from partners.models import Partner, Vacancy, VacancyCategory
from payments.models import Payment
from payments.services import create_payment
from photos.models import Album, Photo
from pizzas.models import Product
from utils.snippets import datetime_to_lectureyear

_faker = Faker(["en_US"])
_local_faker = Faker(["nl_NL"])
_pizza_name_faker = Faker("it_IT")
_current_tz = timezone.get_current_timezone()


def _generate_title():
    words = _faker.words(random.randint(2, 4))
    return " ".join([word.capitalize() for word in words])


def maintain_integrity(func):
    def wrapper(*args, **kwargs):
        try_amnt = 0
        while True:
            try:
                return func(*args, **kwargs)
            except IntegrityError as e:
                try_amnt += 1
                if try_amnt > 10:
                    raise CommandError("Unable to create an object") from e

    return wrapper


class _ProfileFactory(factory.Factory):
    class Meta:
        model = Profile

    programme = random.choice(["computingscience", "informationscience"])
    student_number = factory.LazyAttribute(lambda x: _faker.numerify(text="s#######"))
    starting_year = factory.LazyAttribute(
        lambda x: random.randint(1990, date.today().year)
    )

    address_street = factory.LazyAttribute(lambda x: _local_faker.street_address())
    address_postal_code = factory.LazyAttribute(lambda x: _faker.postcode())
    address_city = factory.LazyAttribute(lambda x: _faker.city())
    address_country = random.choice(["NL", "DE", "BE"])

    phone_number = f"+31{_faker.numerify(text='##########')}"


def get_event_to_register_for(member):
    for event in Event.objects.filter(published=True).order_by("?"):
        if event.registration_required and not event.reached_participants_limit():
            if member.id not in event.registrations.values_list("member", flat=True):
                return event
    return None


class Command(BaseCommand):
    """Command to create fake data to populate the site."""

    help = "Creates fake data to test the site with"

    def add_arguments(self, parser):
        """Add arguments to the argument parser.

        :param parser: the argument parser
        """
        parser.add_argument(
            "-a",
            "--all",
            action="store_true",
            help="Fully populate a database with fixtures",
        )
        parser.add_argument(
            "-b", "--board", type=int, help="The amount of fake boards to add"
        )
        parser.add_argument(
            "-c", "--committee", type=int, help="The amount of fake committees to add"
        )
        parser.add_argument(
            "-d",
            "--document",
            type=int,
            help="The amount of fake miscellaneous documents to add",
        )
        parser.add_argument(
            "-e", "--event", type=int, help="The amount of fake events to add"
        )
        parser.add_argument(
            "-n", "--newsletter", type=int, help="The amount of fake newsletters to add"
        )
        parser.add_argument(
            "-p", "--partner", type=int, help="The amount of fake partners to add"
        )
        parser.add_argument(
            "-i", "--pizza", type=int, help="The amount of fake pizzas to add"
        )
        parser.add_argument(
            "-s", "--society", type=int, help="The amount of fake societies to add"
        )
        parser.add_argument(
            "-u", "--user", type=int, help="The amount of fake users to add"
        )
        parser.add_argument(
            "-w", "--vacancy", type=int, help="The amount of fake vacancies to add"
        )
        parser.add_argument("--course", type=int, help="The amount of courses to add")
        parser.add_argument(
            "-r",
            "--registration",
            type=int,
            help="The amount of event registrations to add",
        )
        parser.add_argument("--payment", type=int, help="The amount of payments to add")
        parser.add_argument(
            "--photoalbum", type=int, help="The amount of photo albums to add"
        )

    def create_board(self, lecture_year):
        """Create a new board.

        :param int lecture_year: the  lecture year this board was active
        """
        self.stdout.write("Creating a board")
        members = Member.objects.all()
        if len(members) < 6:
            self.stdout.write("Your database does not contain 6 users.")
            self.stdout.write(f"Creating {6 - len(members)} more users.")
            for __ in range(6 - len(members)):
                self.create_user()

        board = Board()

        board.name = f"Board {lecture_year}-{lecture_year+1}"
        while Board.objects.filter(name=board.name).exists():
            lecture_year = lecture_year - 1
            board.name = f"Board {lecture_year}-{lecture_year+1}"

        board.description = _faker.paragraph()

        igen = IconGenerator(5, 5)  # 5x5 blocks
        icon = igen.generate(
            board.name, 480, 480, padding=(10, 10, 10, 10), output_format="jpeg"
        )  # 620x620 pixels, with 10 pixels padding on each side
        board.photo = SimpleUploadedFile(f"{board.name}.jpg", icon, "image/jpeg")

        board.since = date(year=lecture_year, month=9, day=1)
        board.until = date(year=lecture_year + 1, month=8, day=31)
        board.active = True
        board.contact_email = _faker.safe_email()

        board.full_clean()
        board.save()

        # Add members
        board_members = random.sample(list(members), random.randint(5, 6))
        for member in board_members:
            self.create_member_group_membership(member, board)

        # Make one member the chair
        chair = random.choice(board.membergroupmembership_set.all())
        chair.until = None
        chair.chair = True

        chair.full_clean()
        chair.save()

    @maintain_integrity
    def create_member_group(self, group_model):
        """Create a MemberGroup."""
        self.stdout.write("Creating a membergroup")
        members = Member.objects.all()
        if len(members) < 6:
            self.stdout.write("Your database does not contain 6 users.")
            self.stdout.write(f"Creating {6 - len(members)} more users.")
            for __ in range(6 - len(members)):
                self.create_user()
            members = Member.objects.all()

        member_group = group_model()

        member_group.name = _generate_title()
        member_group.description = _faker.paragraph()

        igen = IconGenerator(5, 5)  # 5x5 blocks
        icon = igen.generate(
            member_group.name,
            480,
            480,
            padding=(10, 10, 10, 10),
            output_format="jpeg",
        )  # 620x620 pixels, with 10 pixels padding on each side
        member_group.photo = SimpleUploadedFile(
            member_group.name + ".jpg", icon, "image/jpeg"
        )

        member_group.since = _faker.date_time_between("-10y", "+30d").date()

        if random.random() < 0.1:
            month = timedelta(days=30)
            member_group.until = _faker.date_time_between_dates(
                member_group.since + 12 * month, member_group.since + 60 * month
            ).date()

        member_group.active = random.random() < 0.9
        member_group.contact_email = _faker.safe_email()

        member_group.full_clean()
        member_group.save()

        # Add members
        committee_members = random.sample(list(members), random.randint(2, 6))
        for member in committee_members:
            self.create_member_group_membership(member, member_group)

        # Make one member the chair
        chair = random.choice(member_group.membergroupmembership_set.all())
        chair.until = None
        chair.chair = True

        chair.full_clean()
        chair.save()

    def create_member_group_membership(self, member, group):
        """Create member group membership.

        :param member: the member to add to the committee
        :param group: the group to add the member to
        """
        self.stdout.write("Creating a group membership")
        membership = MemberGroupMembership()

        membership.member = member
        membership.group = group

        today = date.today()
        membership.since = _faker.date_time_between_dates(
            group.since,
            group.until - timedelta(days=3)
            if group.until
            else group.since + timedelta(days=365),
        ).date()

        if random.random() < 0.2 and membership.since < today:
            membership.until = _faker.date_time_between_dates(
                membership.since,
                group.until if group.until else group.since + timedelta(days=2 * 365),
            ).date()

        membership.full_clean()
        membership.save()

    @maintain_integrity
    def create_event(self):
        """Create an event."""
        self.stdout.write("Creating an event")
        groups = MemberGroup.objects.all()
        if len(groups) == 0:
            self.stdout.write("Your database does not contain any member groups.")
            self.stdout.write("Creating a committee.")
            self.create_member_group(Committee)
            groups = MemberGroup.objects.all()
        event = Event()

        event.description = _faker.paragraph()
        event.caption = _faker.sentence()
        event.title = _generate_title()
        event.start = _faker.date_time_between("-30d", "+120d", _current_tz)
        duration = math.ceil(random.expovariate(0.2))
        event.end = event.start + timedelta(hours=duration)
        event.save()
        event.organisers.add(*random.sample(list(groups), random.randint(1, 3)))
        event.category = random.choice(EVENT_CATEGORIES)[0]
        event.fine = 5
        event.slug = slugify(event.title) + "-" + str(event.start.year)

        if random.random() < 0.5:
            week = timedelta(days=7)
            event.registration_start = _faker.date_time_between_dates(
                datetime_start=event.start - 4 * week,
                datetime_end=event.start - week,
                tzinfo=_current_tz,
            )
            event.registration_end = _faker.date_time_between_dates(
                datetime_start=event.registration_start,
                datetime_end=event.start,
                tzinfo=_current_tz,
            )
            event.cancel_deadline = _faker.date_time_between_dates(
                datetime_start=event.registration_end,
                datetime_end=event.start,
                tzinfo=_current_tz,
            )
            event.optional_registrations = False

        event.location = _local_faker.street_address()
        event.map_location = event.location
        event.send_cancel_email = False

        if random.random() < 0.5:
            event.price = Decimal(random.randint(100, 2500)) / Decimal(100)
            event.fine = max(
                5.0,
                Decimal(
                    random.randint(round(100 * event.price), round(500 * event.price))
                )
                / Decimal(100),
            )

        if random.random() < 0.5:
            event.max_participants = random.randint(20, 200)

        event.published = random.random() < 0.9

        event.full_clean()
        event.save()

    @maintain_integrity
    def create_partner(self, type="normal"):
        """Create a new random partner."""
        self.stdout.write("Creating a partner")
        partner = Partner()

        partner.name = f"{_faker.company()} {_faker.company_suffix()}"
        partner.slug = _faker.slug()
        partner.link = _faker.uri()
        partner.company_profile = _faker.paragraph(nb_sentences=10)
        igen = IconGenerator(5, 5)  # 5x5 blocks
        icon = igen.generate(
            partner.name,
            480,
            480,
            padding=(10, 10, 10, 10),
            output_format="jpeg",
        )  # 620x620 pixels, with 10 pixels padding on each side
        partner.logo = SimpleUploadedFile(partner.name + ".jpg", icon, "image/jpeg")

        partner.address = _local_faker.street_name() + " " + str(random.randint(1, 300))
        partner.zip_code = _local_faker.postcode()
        partner.city = _local_faker.city()
        partner.country = random.choice(["NL", "DE", "BE", "GB"])

        match type:
            case "normal":
                pass
            case "main":
                partner.is_main_partner = True
            case "local":
                partner.is_local_partner = True
            case "inactive":
                partner.is_active = False

        partner.full_clean()
        partner.save()

    def create_pizza(self):
        """Create a new random pizza product."""
        self.stdout.write("Creating a pizza product")

        product = Product()

        product.name = f"Pizza {_pizza_name_faker.last_name()}"
        product.description = _faker.sentence()
        product.price = Decimal(random.randint(250, 1000)) / Decimal(100)
        product.available = random.random() < 0.9

        product.full_clean()
        product.save()

    @maintain_integrity
    def create_user(self):
        """Create a new random user."""
        self.stdout.write("Creating a user")

        fakeprofile = _faker.profile()
        fakeprofile["password"] = "".join(
            random.choice(string.ascii_uppercase + string.digits) for _ in range(16)
        )
        user = get_user_model().objects.create_user(
            fakeprofile["username"],
            fakeprofile["mail"],
            fakeprofile["password"],
        )

        user.first_name = fakeprofile["name"].split()[0]
        user.last_name = " ".join(fakeprofile["name"].split()[1:])

        profile = _ProfileFactory()
        profile.user_id = user.id
        profile.birthday = fakeprofile["birthdate"]
        profile.website = fakeprofile["website"][0]

        igen = IconGenerator(5, 5)  # 5x5 blocks
        icon = igen.generate(
            user.username,
            480,
            480,
            padding=(10, 10, 10, 10),
            output_format="jpeg",
        )  # 620x620 pixels, with 10 pixels padding on each side
        profile.photo = SimpleUploadedFile(
            fakeprofile["username"] + ".jpg", icon, "image/jpeg"
        )

        membership = Membership()
        membership.user_id = user.id
        membership.since = _faker.date_time_between(
            start_date="-4y", end_date="now", tzinfo=None
        )
        membership.until = random.choice(
            [
                _faker.date_time_between(
                    start_date=membership.since, end_date="+2y", tzinfo=None
                ),
                None,
            ]
        )
        membership.type = random.choice([t[0] for t in Membership.MEMBERSHIP_TYPES])

        user.full_clean()
        user.save()
        profile.full_clean()
        profile.save()
        membership.full_clean()
        membership.save()

    def create_vacancy(self, partners, categories):
        """Create a new random vacancy.

        :param partners: the partners to choose a partner from
        :param categories: the categories to choose this vacancy from
        """
        self.stdout.write("Creating a vacancy")
        vacancy = Vacancy()

        vacancy.title = _faker.job()
        vacancy.description = _faker.paragraph(nb_sentences=10)
        vacancy.link = _faker.uri()

        if random.random() < 0.75:
            vacancy.partner = random.choice(partners)
        else:
            vacancy.company_name = f"{_faker.company()} {_faker.company_suffix()}"
            igen = IconGenerator(5, 5)  # 5x5 blocks
            icon = igen.generate(
                vacancy.company_name,
                480,
                480,
                padding=(10, 10, 10, 10),
                output_format="jpeg",
            )  # 620x620 pixels, with 10 pixels padding on each side
            vacancy.company_logo = SimpleUploadedFile(
                vacancy.company_name + ".jpg", icon, "image/jpeg"
            )

        vacancy.full_clean()
        vacancy.save()

        vacancy.categories.set(random.sample(list(categories), random.randint(0, 3)))

    def create_vacancy_category(self):
        """Create new random vacancy categories."""
        self.stdout.write("Creating a new vacancy category")
        category = VacancyCategory()

        category.name = _faker.text(max_nb_chars=30)
        category.slug = _faker.slug()

        category.full_clean()
        category.save()

    def create_document(self):
        """Create new random documents."""
        self.stdout.write("Creating a document")
        doc = Document()

        doc.name = _faker.text(max_nb_chars=30)
        doc.category = random.choice([c[0] for c in Document.DOCUMENT_CATEGORIES])
        doc.members_only = random.random() < 0.75
        doc.file = SimpleUploadedFile(
            f"{doc.name}.txt", _faker.text(max_nb_chars=120).encode()
        )
        doc.full_clean()
        doc.save()

    def create_newsletter(self):
        self.stdout.write("Creating a new newsletter")
        newsletter = Newsletter()

        newsletter.title = _generate_title()
        newsletter.description = _faker.paragraph()
        newsletter.date = _faker.date_time_between("-3m", "+3m", _current_tz)

        newsletter.clean()  # full_clean does not work because of rendered_file
        newsletter.save()

        for _ in range(random.randint(1, 5)):
            item = NewsletterItem()
            item.title = _generate_title()
            item.description = _faker.paragraph()
            item.newsletter = newsletter
            item.full_clean()
            item.save()

        for _ in range(random.randint(1, 5)):
            item = NewsletterEvent()
            item.title = _generate_title()
            item.description = _faker.paragraph()
            item.newsletter = newsletter

            item.what = item.title
            item.where = _faker.city()
            item.start_datetime = _faker.date_time_between("-1y", "+3m", _current_tz)
            duration = math.ceil(random.expovariate(0.2))
            item.end_datetime = item.start_datetime + timedelta(hours=duration)

            if random.random() < 0.5:
                item.show_costs_warning = True
                item.price = Decimal(random.randint(100, 2500)) / Decimal(100)
                item.penalty_costs = max(
                    5.0,
                    Decimal(
                        random.randint(round(100 * item.price), round(500 * item.price))
                    )
                    / Decimal(100),
                )
            item.full_clean()
            item.save()

    def create_course(self):
        self.stdout.write("Creating a new course")
        course = Course()

        course.name = _generate_title()
        course.ec = 3 if random.random() < 0.5 else 6

        course.course_code = "NWI-" + "".join(random.choices(string.digits, k=5))

        course.since = random.randint(2016, 2020)
        if random.random() < 0.5:
            course.until = max(course.since + random.randint(1, 5), datetime.now().year)

        # Save so we can add categories
        course.save()

        for category in Category.objects.order_by("?")[: random.randint(1, 3)]:
            course.categories.add(category)

        course.full_clean()
        course.save()

    def create_event_registration(self, event_to_register_for=None):
        self.stdout.write("Creating an event registration")
        registration = EventRegistration()

        eligible = Member.objects.filter(registration_member_choices_limit())
        registration.member = eligible.order_by("?")[0]

        possible_event = (
            event_to_register_for
            if event_to_register_for
            else get_event_to_register_for(registration.member)
        )

        if not possible_event:
            self.stdout.write("No possible events to register for")
            self.stdout.write("Creating a new event")
            self.create_event()
            possible_event = get_event_to_register_for(registration.member)

        if not possible_event:
            self.stdout.write("Could not create event")
            return None

        registration.event = possible_event

        registration.date = registration.event.registration_start

        registration.full_clean()
        registration.save()

        return registration

    def create_payment(self):
        self.stdout.write("Creating a payment")

        possible_events = list(
            filter(
                lambda e: e.registrations.exists(),
                Event.objects.filter(price__gt=0).order_by("?"),
            )
        )
        if len(possible_events) == 0:
            print("No event where can be payed could be found, creating a new event")
            self.create_event()
            possible_events = list(
                filter(
                    lambda e: e.registrations.exists(),
                    Event.objects.filter(price__gt=0).order_by("?"),
                )
            )

        if len(possible_events) == 0:
            print("Could not create the event for an unexpected reason.")
            return

        event = possible_events[0]
        if len(event.registrations) == 0:
            print("No registrations found. Create some more registrations first")
            return

        registration = event.registrations.order_by("?")[0]

        superusers = Member.objects.filter(is_superuser=True)
        if not superusers:
            print(
                "There is no member which is also a superuser. Creating payments without this isn't possible!"
            )
            print("Please add an membership to the superuser.")
            return

        payment = create_payment(
            registration,
            superusers[0],
            random.choice([Payment.CASH, Payment.CARD, Payment.WIRE]),
        )

        payment.full_clean()

    @maintain_integrity
    def create_photo_album(self):
        self.stdout.write("Creating a photo album")
        album = Album()

        album.title = _generate_title()

        album.date = _faker.date_between("-1y", "today")

        album.slug = slugify("-".join([str(album.date), album.title]))

        # normally this is set in save(), but required for validation
        album.dirname = album.slug

        if random.random() < 0.25:
            album.hidden = True
        if random.random() < 0.5:
            album.shareable = True

        album.full_clean()
        album.save()

        for _ in range(random.randint(20, 30)):
            self.create_photo(album)

    def create_photo(self, album):
        self.stdout.write("Creating a photo")
        photo = Photo()

        photo.album = album

        name = _generate_title()

        igen = IconGenerator(12, 12)
        icon = igen.generate(
            token_hex(16),
            480,
            480,
            padding=(10, 10, 10, 10),
            output_format="jpeg",
        )  # 620x620 pixels, with 10 pixels padding on each side
        photo.file = SimpleUploadedFile(f"{name}.jpg", icon, "image/jpeg")

        photo.full_clean()
        photo.save()

    def handle(self, *args, **options):
        """Handle the command being executed.

        :param options: the passed-in options
        """
        opts = [
            "all",
            "board",
            "committee",
            "event",
            "partner",
            "pizza",
            "user",
            "vacancy",
            "document",
            "newsletter",
            "course",
            "registration",
            "payment",
            "photoalbum",
        ]

        if all(not options[opt] for opt in opts):
            self.stdout.write(
                "Use ./manage.py help createfixtures to find out"
                " how to call this command"
            )

        if options["all"]:
            self.stdout.write("all argument given, overwriting all other inputs")
            options = {
                "user": 20,
                "board": 3,
                "committee": 3,
                "society": 3,
                "event": 20,
                "partner": 6,
                "vacancy": 4,
                "pizza": 5,
                "newsletter": 2,
                "document": 8,
                "course": 10,
                "registration": 20,
                "payment": 5,
                "photoalbum": 5,
            }

        # Users need to be generated before boards and committees
        if options["user"]:
            for __ in range(options["user"]):
                self.create_user()

        if options["board"]:
            lecture_year = datetime_to_lectureyear(date.today())
            for i in range(options["board"]):
                self.create_board(lecture_year - i)

        # Member groups need to be generated before events
        if options["committee"]:
            for __ in range(options["committee"]):
                self.create_member_group(Committee)

        if options["society"]:
            for __ in range(options["society"]):
                self.create_member_group(Society)

        if options["event"]:
            for __ in range(options["event"]):
                self.create_event()

        # Partners need to be generated before vacancies
        if options["partner"]:
            local_partners = options["partner"] // 3
            for __ in range(local_partners):
                self.create_partner("local")
            other_partners = options["partner"] - local_partners
            for __ in range(other_partners):
                self.create_partner()
            inactive_partners = options["partner"] // 5
            for __ in range(inactive_partners):
                self.create_partner("inactive")

            # Make one of the partners the main partner
            try:
                Partner.objects.get(is_main_partner=True)
            except Partner.DoesNotExist:
                self.create_partner("main")

        if options["vacancy"]:
            categories = VacancyCategory.objects.all()
            if not categories:
                self.stdout.write("No vacancy categories found. Creating 5 categories.")
                for __ in range(5):
                    self.create_vacancy_category()
                categories = VacancyCategory.objects.all()

            partners = Partner.objects.all()
            for __ in range(options["vacancy"]):
                self.create_vacancy(partners, categories)

        if options["pizza"]:
            for __ in range(options["pizza"]):
                self.create_pizza()

        if options["newsletter"]:
            for __ in range(options["newsletter"]):
                self.create_newsletter()

        if options["document"]:
            for __ in range(options["document"]):
                self.create_document()

        # Courses need to be created before exams and summaries
        if options["course"]:
            # Create course categories if needed
            if len(Category.objects.all()) < 5:
                for _ in range(5):
                    category = Category()
                    category.name = _generate_title()

                    category.save()

            for _ in range(options["course"]):
                self.create_course()

        # Registrations need to be created before payments
        if options["registration"]:
            for _ in range(options["registration"]):
                self.create_event_registration()

        if options["payment"]:
            for _ in range(options["payment"]):
                self.create_payment()

        if options["photoalbum"]:
            for _ in range(options["photoalbum"]):
                self.create_photo_album()