Esempio n. 1
0
class Printer(SafeDeleteModel):
    class Meta:
        default_manager_name = 'objects'

    PAUSE = 'PAUSE'
    NONE = 'NONE'
    ACTION_ON_FAILURE = (
        (NONE, 'Just notify me'),
        (PAUSE, 'Pause the printer and notify me'),
    )

    name = models.CharField(max_length=200, null=False)
    auth_token = models.CharField(max_length=28,
                                  unique=True,
                                  null=False,
                                  blank=False)
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=False)
    current_print = models.OneToOneField('Print',
                                         on_delete=models.SET_NULL,
                                         null=True,
                                         blank=True,
                                         related_name='not_used')
    action_on_failure = models.CharField(
        max_length=10,
        choices=ACTION_ON_FAILURE,
        default=PAUSE,
    )
    watching_enabled = models.BooleanField(default=True, db_column="watching")
    tools_off_on_pause = models.BooleanField(default=True)
    bed_off_on_pause = models.BooleanField(default=False)
    retract_on_pause = models.FloatField(null=False, default=6.5)
    lift_z_on_pause = models.FloatField(null=False, default=2.5)
    detective_sensitivity = models.FloatField(null=False, default=1.0)

    archived_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = PrinterManager()
    with_archived = SafeDeleteManager()

    @property
    def status(self):
        return dict_or_none(cache.printer_status_get(self.id))

    @property
    def pic(self):
        pic_data = cache.printer_pic_get(self.id)

        return dict_or_none(pic_data)

    @property
    def settings(self):
        p_settings = cache.printer_settings_get(self.id)

        for key in ('webcam_flipV', 'webcam_flipH', 'webcam_rotate90'):
            p_settings[key] = p_settings.get(key, 'False') == 'True'
        p_settings['ratio169'] = p_settings.get('webcam_streamRatio',
                                                '4:3') == '16:9'

        if p_settings.get('temp_profiles'):
            p_settings['temp_profiles'] = json.loads(
                p_settings.get('temp_profiles'))

        return p_settings

    # should_watch and not_watching_reason follow slightly different rules
    # should_watch is used by the plugin. Therefore printing status is not a factor, otherwise we may have a feedback cycle:
    #    printer paused -> update server cache -> send should_watch to plugin -> udpate server
    # not_watching_reason is used by the web app and mobile app

    def should_watch(self):
        if not self.watching_enabled or self.user.dh_balance < 0:
            return False

        return self.current_print is not None and self.current_print.alert_muted_at is None

    def not_watching_reason(self):
        if not self.watching_enabled:
            return '"Watch for failures" is turned off'

        if self.user.dh_balance < 0:
            return "You have run out of Detective Hours"

        if not self.actively_printing():
            return "Printer is not actively printing"

        if self.current_print is not None and self.current_print.alert_muted_at is not None:
            return "Alerts are muted for current print"

        return None

    def actively_printing(self):
        printer_cur_state = cache.printer_status_get(self.id, 'state')

        return printer_cur_state and printer_cur_state.get('flags', {}).get(
            'printing', False)

    def update_current_print(self, filename, current_print_ts):
        if current_print_ts == -1:  # Not printing
            if self.current_print:
                if self.current_print.started_at < (timezone.now() -
                                                    timedelta(hours=10)):
                    self.unset_current_print()
                else:
                    LOGGER.warn(
                        f'current_print_ts=-1 received when current print is still active. print_id: {self.current_print_id} - printer_id: {self.id}'
                    )

            return

        # currently printing

        if self.current_print:
            if self.current_print.ext_id == current_print_ts:
                return
            # Unknown bug in plugin that causes current_print_ts not unique

            if self.current_print.ext_id in range(
                    current_print_ts - 20, current_print_ts +
                    20) and self.current_print.filename == filename:
                LOGGER.warn(
                    f'Apparently skewed print_ts received. ts1: {self.current_print.ext_id} - ts2: {current_print_ts} - print_id: {self.current_print_id} - printer_id: {self.id}'
                )

                return
            LOGGER.warn(
                f'Print not properly ended before next start. Stale print_id: {self.current_print_id} - printer_id: {self.id}'
            )
            self.unset_current_print()
            self.set_current_print(filename, current_print_ts)
        else:
            self.set_current_print(filename, current_print_ts)

    def unset_current_print(self):
        print = self.current_print
        self.current_print = None
        self.save()

        self.printerprediction.reset_for_new_print()

        if print.cancelled_at is None:
            print.finished_at = timezone.now()
            print.save()

        PrintEvent.create(print, PrintEvent.ENDED)
        self.send_should_watch_status()

    def set_current_print(self, filename, current_print_ts):
        if not current_print_ts or current_print_ts == -1:
            raise Exception(
                f'Invalid current_print_ts when trying to set current_print: {current_print_ts}'
            )

        try:
            cur_print, _ = Print.objects.get_or_create(
                user=self.user,
                printer=self,
                ext_id=current_print_ts,
                defaults={
                    'filename': filename.strip(),
                    'started_at': timezone.now()
                },
            )
        except IntegrityError:
            raise ResurrectionError(
                'Current print is deleted! printer_id: {} | print_ts: {} | filename: {}'
                .format(self.id, current_print_ts, filename))

        if cur_print.ended_at():
            if cur_print.ended_at() > (
                    timezone.now() - timedelta(seconds=30)
            ):  # Race condition. Some msg with valid print_ts arrived after msg with print_ts=-1
                return
            else:
                raise ResurrectionError(
                    'Ended print is re-surrected! printer_id: {} | print_ts: {} | filename: {}'
                    .format(self.id, current_print_ts, filename))

        self.current_print = cur_print
        self.save()

        self.printerprediction.reset_for_new_print()
        PrintEvent.create(cur_print, PrintEvent.STARTED)
        self.send_should_watch_status()

    ## return: succeeded? ##
    def resume_print(self, mute_alert=False):
        if self.current_print is None:  # when a link on an old email is clicked
            return False

        self.current_print.paused_at = None
        self.current_print.save()

        self.acknowledge_alert(Print.NOT_FAILED)
        self.send_octoprint_command('resume')

        return True

    ## return: succeeded? ##
    def pause_print(self):
        if self.current_print is None:
            return False

        self.current_print.paused_at = timezone.now()
        self.current_print.save()

        args = {
            'retract': self.retract_on_pause,
            'lift_z': self.lift_z_on_pause
        }

        if self.tools_off_on_pause:
            args['tools_off'] = True

        if self.bed_off_on_pause:
            args['bed_off'] = True
        self.send_octoprint_command('pause', args=args)

        return True

    ## return: succeeded? ##
    def cancel_print(self):
        if self.current_print is None:  # when a link on an old email is clicked
            return False

        self.acknowledge_alert(Print.FAILED)
        self.send_octoprint_command('cancel')

        return True

    def set_alert(self):
        self.current_print.alerted_at = timezone.now()
        self.current_print.save()

    def acknowledge_alert(self, alert_overwrite):
        if not self.current_print.alerted_at:  # Not even alerted. Shouldn't be here. Maybe user error?
            return

        self.current_print.alert_acknowledged_at = timezone.now()
        self.current_print.alert_overwrite = alert_overwrite
        self.current_print.save()

    def mute_current_print(self, muted):
        self.current_print.alert_muted_at = timezone.now() if muted else None
        self.current_print.save()

        if muted:
            PrintEvent.create(self.current_print, PrintEvent.ALERT_MUTED)
        else:
            PrintEvent.create(self.current_print, PrintEvent.ALERT_UNMUTED)

        self.send_should_watch_status()

    # messages to printer

    def send_octoprint_command(self, command, args={}):
        channels.send_msg_to_printer(
            self.id, {'commands': [{
                'cmd': command,
                'args': args
            }]})

    def send_should_watch_status(self, refresh=True):
        if refresh:
            self.refresh_from_db()
        channels.send_msg_to_printer(
            self.id, {'remote_status': {
                'should_watch': self.should_watch()
            }})

    def __str__(self):
        return str(self.id)
class Printer(SafeDeleteModel):
    class Meta:
        default_manager_name = 'objects'

    PAUSE = 'PAUSE'
    NONE = 'NONE'
    ACTION_ON_FAILURE = (
        (NONE, 'Just notify me'),
        (PAUSE, 'Pause the printer and notify me'),
    )

    name = models.CharField(max_length=200, null=False)
    auth_token = models.CharField(max_length=28,
                                  unique=True,
                                  null=False,
                                  blank=False)
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=False)
    current_print = models.OneToOneField('Print',
                                         on_delete=models.SET_NULL,
                                         null=True,
                                         blank=True,
                                         related_name='not_used')
    action_on_failure = models.CharField(
        max_length=10,
        choices=ACTION_ON_FAILURE,
        default=PAUSE,
    )
    watching = models.BooleanField(default=True)
    tools_off_on_pause = models.BooleanField(default=True)
    bed_off_on_pause = models.BooleanField(default=False)
    retract_on_pause = models.FloatField(null=False, default=6.5)
    lift_z_on_pause = models.FloatField(null=False, default=2.5)
    detective_sensitivity = models.FloatField(null=False, default=1.0)

    archived_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = PrinterManager()
    with_archived = SafeDeleteManager()

    if os.environ.get('ENALBE_HISTORY', '') == 'True':
        history = HistoricalRecords(excluded_fields=['updated_at'])

    @property
    def status(self):
        status_data = redis.printer_status_get(self.id)

        for k, v in status_data.items():
            status_data[k] = json.loads(v)

        return dict_or_none(status_data)

    @property
    def pic(self):
        pic_data = redis.printer_pic_get(self.id)

        return dict_or_none(pic_data)

    @property
    def settings(self):
        p_settings = redis.printer_settings_get(self.id)

        for key in ('webcam_flipV', 'webcam_flipH', 'webcam_rotate90'):
            p_settings[key] = p_settings.get(key, 'False') == 'True'
        p_settings['ratio169'] = p_settings.get('webcam_streamRatio',
                                                '4:3') == '16:9'

        if p_settings.get('temp_profiles'):
            p_settings['temp_profiles'] = json.loads(
                p_settings.get('temp_profiles'))

        return p_settings

    def should_watch(self):
        if not self.watching or self.user.dh_balance < 0:
            return False

        return self.current_print is not None and self.current_print.alert_muted_at is None

    def actively_printing(self):
        printer_cur_state = redis.printer_status_get(self.id, 'state')

        return printer_cur_state and json.loads(printer_cur_state).get(
            'flags', {}).get('printing', False)

    def update_current_print(self, filename, current_print_ts):
        if current_print_ts == -1:  # Not printing
            if self.current_print:
                if self.current_print.started_at < (timezone.now() -
                                                    timedelta(hours=10)):
                    self.unset_current_print()
                else:
                    LOGGER.warn(
                        f'current_print_ts=-1 received when current print is still active. print_id: {self.current_print_id} - printer_id: {self.id}'
                    )

            return

        # currently printing

        if self.current_print:
            if self.current_print.ext_id == current_print_ts:
                return
            # Unknown bug in plugin that causes current_print_ts not unique

            if self.current_print.ext_id in range(
                    current_print_ts - 20, current_print_ts +
                    20) and self.current_print.filename == filename:
                LOGGER.warn(
                    f'Apparently skewed print_ts received. ts1: {self.current_print.ext_id} - ts2: {current_print_ts} - print_id: {self.current_print_id} - printer_id: {self.id}'
                )

                return
            LOGGER.warn(
                f'Print not properly ended before next start. Stale print_id: {self.current_print_id} - printer_id: {self.id}'
            )
            self.unset_current_print()
            self.set_current_print(filename, current_print_ts)
        else:
            self.set_current_print(filename, current_print_ts)

    def unset_current_print(self):
        print = self.current_print
        self.current_print = None
        self.save()

        self.printerprediction.reset_for_new_print()

        if print.cancelled_at is None:
            print.finished_at = timezone.now()
            print.save()

        PrintEvent.create(print, PrintEvent.ENDED)
        self.send_should_watch_status()

    def set_current_print(self, filename, current_print_ts):
        if not current_print_ts or current_print_ts == -1:
            raise Exception(
                f'Invalid current_print_ts when trying to set current_print: {current_print_ts}'
            )

        cur_print, _ = Print.objects.get_or_create(
            user=self.user,
            printer=self,
            ext_id=current_print_ts,
            defaults={
                'filename': filename,
                'started_at': timezone.now()
            },
        )

        if cur_print.ended_at():
            if cur_print.ended_at() > (
                    timezone.now() - timedelta(seconds=30)
            ):  # Race condition. Some msg with valid print_ts arrived after msg with print_ts=-1
                return
            else:
                raise Exception(
                    'Ended print is re-surrected! printer_id: {} | print_ts: {} | filename: {}'
                    .format(self.id, current_print_ts, filename))

        self.current_print = cur_print
        self.save()

        self.printerprediction.reset_for_new_print()
        PrintEvent.create(cur_print, PrintEvent.STARTED)
        self.send_should_watch_status()

    ## return: succeeded, user_credited ##
    def resume_print(self, mute_alert=False):
        if self.current_print is None:  # when a link on an old email is clicked
            return False, False

        self.current_print.paused_at = None
        self.current_print.save()

        user_credited = self.acknowledge_alert(Print.NOT_FAILED)
        self.send_octoprint_command('resume')

        return True, user_credited

    ## return: succeeded, user_credited ##
    def pause_print(self):
        if self.current_print is None:
            return False, False

        self.current_print.paused_at = timezone.now()
        self.current_print.save()

        args = {
            'retract': self.retract_on_pause,
            'lift_z': self.lift_z_on_pause
        }

        if self.tools_off_on_pause:
            args['tools_off'] = True

        if self.bed_off_on_pause:
            args['bed_off'] = True
        self.send_octoprint_command('pause', args=args)

        return True, False

    ## return: succeeded, user_credited ##
    def cancel_print(self):
        if self.current_print is None:  # when a link on an old email is clicked
            return False, False

        user_credited = self.acknowledge_alert(Print.FAILED)
        self.send_octoprint_command('cancel')

        return True, user_credited

    def set_alert(self):
        self.current_print.alerted_at = timezone.now()
        self.current_print.save()

    def acknowledge_alert(self, alert_overwrite):
        if not self.current_print.alerted_at:
            return False

        user_credited = False

        if self.current_print.alert_overwrite is None:
            celery_app.send_task('app_ent.tasks.credit_dh_for_contribution',
                                 args=[
                                     self.user.id, 1,
                                     'Credit | Tag "{}"'.format(
                                         self.current_print.filename[:100])
                                 ])
            user_credited = True

        self.current_print.alert_acknowledged_at = timezone.now()
        self.current_print.alert_overwrite = alert_overwrite
        self.current_print.save()

        return user_credited

    def mute_current_print(self, muted):
        self.current_print.alert_muted_at = timezone.now() if muted else None
        self.current_print.save()

        if muted:
            PrintEvent.create(self.current_print, PrintEvent.ALERT_MUTED)
        else:
            PrintEvent.create(self.current_print, PrintEvent.ALERT_UNMUTED)

        self.send_should_watch_status()

    ## messages to printer

    def send_octoprint_command(self, command, args={}):
        channels.send_msg_to_printer(
            self.id, {'commands': [{
                'cmd': command,
                'args': args
            }]})

    def send_should_watch_status(self):
        self.refresh_from_db()
        channels.send_msg_to_printer(
            self.id, {'remote_status': {
                'should_watch': self.should_watch()
            }})

    def __str__(self):
        return str(self.id)
Esempio n. 3
0
class Printer(SafeDeleteModel):
    class Meta:
        default_manager_name = 'objects'

    PAUSE = 'PAUSE'
    NONE = 'NONE'
    ACTION_ON_FAILURE = (
        (NONE, 'Just notify me.'),
        (PAUSE, 'Pause the printer and notify me.'),
    )

    name = models.CharField(max_length=200, null=False)
    auth_token = models.CharField(max_length=28,
                                  unique=True,
                                  null=False,
                                  blank=False)
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=False)
    current_print = models.OneToOneField('Print',
                                         on_delete=models.SET_NULL,
                                         null=True,
                                         blank=True,
                                         related_name='not_used')
    action_on_failure = models.CharField(
        max_length=10,
        choices=ACTION_ON_FAILURE,
        default=PAUSE,
    )
    watching = models.BooleanField(default=True)
    tools_off_on_pause = models.BooleanField(default=True)
    bed_off_on_pause = models.BooleanField(default=False)
    retract_on_pause = models.FloatField(null=False, default=6.5)
    lift_z_on_pause = models.FloatField(null=False, default=2.5)
    detective_sensitivity = models.FloatField(null=False, default=1.0)

    archived_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = PrinterManager()
    with_archived = SafeDeleteManager()

    if os.environ.get('ENALBE_HISTORY', '') == 'True':
        history = HistoricalRecords(excluded_fields=['updated_at'])

    @property
    def status(self):
        status_data = redis.printer_status_get(self.id)
        for k, v in status_data.items():
            status_data[k] = json.loads(v)
        return dict_or_none(status_data)

    @property
    def pic(self):
        pic_data = redis.printer_pic_get(self.id)
        return dict_or_none(pic_data)

    def should_watch(self):
        if not self.watching or self.user.dh_balance < 0:
            return False

        return self.current_print != None and self.current_print.alert_muted_at == None

    def actively_printing(self):
        printer_cur_state = redis.printer_status_get(self.id, 'state')
        return printer_cur_state and json.loads(printer_cur_state).get(
            'flags', {}).get('printing', False)

    def update_current_print(self, filename, current_print_ts):
        if current_print_ts == -1:  # Not printing
            if self.current_print:
                self.unset_current_print()

            return

        # currently printing
        if self.current_print:
            if self.current_print.ext_id != current_print_ts:
                self.unset_current_print()
                self.set_current_print(filename, current_print_ts)
        else:
            self.set_current_print(filename, current_print_ts)

    def unset_current_print(self):
        print = self.current_print
        self.current_print = None
        self.save()

        self.printerprediction.reset_for_new_print()

        if print.cancelled_at is None:
            print.finished_at = timezone.now()
            print.save()

        celery_app.send_task('app.tasks.compile_timelapse', args=[print.id])
        PrintEvent.create(print, PrintEvent.ENDED)
        send_remote_status(self)

    def set_current_print(self, filename, current_print_ts):
        if current_print_ts and current_print_ts != -1:
            cur_print, _ = Print.objects.get_or_create(
                user=self.user,
                printer=self,
                ext_id=current_print_ts,
                defaults={
                    'filename': filename,
                    'started_at': timezone.now()
                },
            )
        else:
            cur_print = Print.objects.create(
                user=self.user,
                printer=self,
                filename=filename,
                started_at=timezone.now(),
            )

        if cur_print.ended_at():
            raise Exception(
                'Ended print is re-surrected! printer_id: {} | print_ts: {} | filename: {}'
                .format(self.id, current_print_ts, filename))

        self.current_print = cur_print
        self.save()

        self.printerprediction.reset_for_new_print()
        PrintEvent.create(cur_print, PrintEvent.STARTED)
        send_remote_status(self)

    ## return: succeeded, user_credited ##
    def resume_print(self, mute_alert=False):
        if self.current_print == None:  # when a link on an old email is clicked
            return False, False

        # TODO: find a more elegant way to prevent rage clicking
        last_commands = self.printercommand_set.order_by('-id')[:1]
        if len(last_commands) > 0 and last_commands[
                0].created_at > timezone.now() - timedelta(seconds=10):
            return False, False

        self.current_print.paused_at = None
        self.current_print.save()

        user_credited = self.acknowledge_alert(Print.NOT_FAILED)
        if mute_alert:
            self.mute_current_print(True)

        self.queue_octoprint_command('resume')
        return True, user_credited

    ## return: succeeded, user_credited ##
    def pause_print(self):
        if self.current_print == None:
            return False, False

        # TODO: find a more elegant way to prevent rage clicking
        last_commands = self.printercommand_set.order_by('-id')[:1]
        if len(last_commands) > 0 and last_commands[
                0].created_at > timezone.now() - timedelta(seconds=10):
            return False, False

        self.current_print.paused_at = timezone.now()
        self.current_print.save()

        args = {
            'retract': self.retract_on_pause,
            'lift_z': self.lift_z_on_pause
        }
        if self.tools_off_on_pause:
            args['tools_off'] = True
        if self.bed_off_on_pause:
            args['bed_off'] = True
        self.queue_octoprint_command('pause', args=args)

        return True, False

    ## return: succeeded, user_credited ##
    def cancel_print(self):
        if self.current_print == None:  # when a link on an old email is clicked
            return False, False

        user_credited = self.acknowledge_alert(Print.FAILED)
        self.queue_octoprint_command('cancel')
        return True, user_credited

    def set_alert(self):
        self.current_print.alerted_at = timezone.now()
        self.current_print.save()

    def acknowledge_alert(self, alert_overwrite):
        if not self.current_print.alerted_at:
            return False

        user_credited = False
        if self.current_print.alert_overwrite == None:
            celery_app.send_task('app_ent.tasks.credit_dh_for_contribution',
                                 args=[
                                     self.user.id, 1,
                                     'Credit | Flag "{}"'.format(
                                         self.current_print.filename[:100])
                                 ])
            user_credited = True

        self.current_print.alert_acknowledged_at = timezone.now()
        self.current_print.alert_overwrite = alert_overwrite
        self.current_print.save()
        return user_credited

    def mute_current_print(self, muted):
        self.current_print.alert_muted_at = timezone.now() if muted else None
        self.current_print.save()
        if muted:
            PrintEvent.create(self.current_print, PrintEvent.ALERT_MUTED)
        else:
            PrintEvent.create(self.current_print, PrintEvent.ALERT_UNMUTED)

        send_remote_status(self)

    def queue_octoprint_command(self, command, args={}, abort_existing=True):
        if abort_existing:
            PrinterCommand.objects.filter(
                printer=self, status=PrinterCommand.PENDING).update(
                    status=PrinterCommand.ABORTED)
        PrinterCommand.objects.create(printer=self,
                                      command=json.dumps({
                                          'cmd': command,
                                          'args': args
                                      }),
                                      status=PrinterCommand.PENDING)

    def __str__(self):
        return self.name
Esempio n. 4
0
class WebsiteContent(TimestampedModel, SafeDeleteModel):
    """ Class for a content component of a website"""

    objects = SafeDeleteManager(WebsiteContentQuerySet)
    all_objects = SafeDeleteAllManager(WebsiteContentQuerySet)
    deleted_objects = SafeDeleteDeletedManager(WebsiteContentQuerySet)

    def upload_file_to(self, filename):
        """Return the appropriate filepath for an upload"""
        site_config = SiteConfig(self.website.starter.config)
        url_parts = [
            site_config.root_url_path,
            self.website.name,
            f"{self.text_id.replace('-', '')}_{filename}",
        ]
        return "/".join([part for part in url_parts if part != ""])

    owner = models.ForeignKey(User, null=True, blank=True, on_delete=SET_NULL)
    updated_by = models.ForeignKey(
        User, null=True, blank=True, on_delete=SET_NULL, related_name="content_updated"
    )
    website = models.ForeignKey(
        "Website", null=False, blank=False, on_delete=models.CASCADE
    )
    text_id = models.CharField(
        max_length=36, null=False, blank=False, default=uuid_string, db_index=True
    )
    title = models.CharField(max_length=512, null=True, blank=True, db_index=True)
    type = models.CharField(max_length=24, blank=False, null=False)
    parent = models.ForeignKey(
        "self", null=True, blank=True, related_name="contents", on_delete=models.CASCADE
    )
    markdown = models.TextField(null=True, blank=True)
    metadata = models.JSONField(null=True, blank=True)
    is_page_content = models.BooleanField(
        default=False,
        help_text=(
            "If True, indicates that this content represents a navigable page, as opposed to some "
            "metadata, configuration, etc."
        ),
    )
    filename = models.CharField(
        max_length=CONTENT_FILENAME_MAX_LEN,
        null=False,
        blank=True,
        default="",
        help_text="The filename of the file that will be created from this object WITHOUT the file extension.",
    )
    dirpath = models.CharField(
        max_length=CONTENT_DIRPATH_MAX_LEN,
        null=False,
        blank=True,
        default="",
        help_text=(
            "The directory path for the file that will be created from this object."
        ),
    )
    file = models.FileField(
        upload_to=upload_file_to, editable=True, null=True, blank=True, max_length=2048
    )

    def calculate_checksum(self) -> str:
        """ Returns a calculated checksum of the content """
        return sha256(
            "\n".join(
                [
                    json.dumps(self.metadata, sort_keys=True),
                    str(self.title),
                    str(self.markdown),
                    self.type,
                    str(self.dirpath),
                    str(self.filename),
                    str(self.file.url if self.file else ""),
                ]
            ).encode("utf-8")
        ).hexdigest()

    @property
    def full_metadata(self) -> Dict:
        """Return the metadata field with file upload included"""
        file_field = self.get_config_file_field()
        if file_field:
            full_metadata = (
                self.metadata
                if (self.metadata and isinstance(self.metadata, dict))
                else {}
            )
            if self.file and self.file.url:
                full_metadata[file_field["name"]] = self.file.url
            else:
                full_metadata[file_field["name"]] = None
            return full_metadata
        return self.metadata

    def get_config_file_field(self) -> Dict:
        """Get the site config file field for the object, if any"""
        site_config = SiteConfig(self.website.starter.config)
        content_config = site_config.find_item_by_name(self.type)
        if content_config:
            return site_config.find_file_field(content_config)

    def save(self, **kwargs):  # pylint: disable=arguments-differ
        """Update dirty flags on save"""
        super().save(**kwargs)
        website = self.website
        website.has_unpublished_live = True
        website.has_unpublished_draft = True
        website.save()

    class Meta:
        constraints = [
            UniqueConstraint(name="unique_text_id", fields=["website", "text_id"]),
            UniqueConstraint(
                name=CONTENT_FILEPATH_UNIQUE_CONSTRAINT,
                fields=("website", "dirpath", "filename"),
                condition=Q(is_page_content=True),
            ),
        ]

    def __str__(self):
        return f"{self.title} [{self.text_id}]" if self.title else str(self.text_id)
Esempio n. 5
0
class Food(CleanModelMixin, SafeDeleteModel):

    class Meta:
        verbose_name = _('etenswaar')
        verbose_name_plural = _('etenswaren')

    def __str__(self):
        return self.name

    objects = SafeDeleteAllManager()
    visisble_objects = SafeDeleteManager()

    name = models.CharField(
        max_length=191,
        verbose_name=_('naam'),
        help_text=('Naam.')
    )
    description = models.TextField(
        blank=True,
        verbose_name=_('beschrijving'),
        help_text=('Beschrijving.')
    )
    cost = CostField(
        verbose_name=_('basisprijs'),
        help_text=(
            'Basisprijs, dit is inclusief de gekozen ingrediënten en '
            'ingrediëntengroepen.'
        )
    )
    amount = RoundingDecimalField(
        decimal_places=3,
        max_digits=7,
        default=1,
        verbose_name=_('standaardhoeveelheid'),
        help_text=('Hoeveelheid die standaard is ingevuld.')
    )
    priority = models.BigIntegerField(
        default=0,
        verbose_name=_('prioriteit'),
        help_text=('Prioriteit.')
    )
    commentable = models.BooleanField(
        default=False,
        verbose_name=_('commentaar mogelijk'),
        help_text=(
            'Of er commentaar kan achter worden gelaten bij het bestellen.'
        )
    )
    enabled = models.BooleanField(
        default=True,
        verbose_name=_('ingeschakeld'),
        help_text=_('Ingeschakeld.')
    )
    last_modified = models.DateTimeField(
        auto_now=True,
        verbose_name=_('laatst aangepast'),
        help_text=('Laatst aangepast.')
    )

    # Relations
    foodtype = models.ForeignKey(
        'FoodType',
        on_delete=models.CASCADE,
        verbose_name=_('type etenswaar'),
        help_text=('Type etenswaar.')
    )
    menu = models.ForeignKey(
        'Menu',
        on_delete=models.CASCADE,
        related_name='food',
        verbose_name=_('menu'),
        help_text=('Menu.')
    )
    ingredients = models.ManyToManyField(
        'Ingredient',
        through='IngredientRelation',
        blank=True,
        verbose_name=_('ingrediënten'),
        help_text=('Ingrediënten.')
    )
    ingredientgroups = models.ManyToManyField(
        'IngredientGroup',
        blank=True,
        verbose_name=_('ingrediëntengroepen'),
        help_text=('Ingrediëntengroepen.')
    )

    # Ordering settings
    wait = models.DurationField(
        default=None,
        null=True,
        blank=True,
        verbose_name=_('wachttijd'),
        help_text=_(
            'Minimum tijd dat op voorhand besteld moet worden. Dit leeg '
            'laten betekent dat deze instelling overgenomen wordt van het '
            'type etenswaar.'
        )
    )
    preorder_time = models.TimeField(
        default=None,
        null=True,
        blank=True,
        verbose_name=_('tijd voorafgaande bestelling'),
        help_text=_(
            'Indien bepaalde waren meer dan een dag op voorhand besteld moeten '
            'worden, moeten ze voor dit tijdstip besteld worden. Dit leeg '
            'laten betekent dat deze instelling overgenomen wordt van het '
            'type etenswaar.'
        )
    )
    preorder_days = models.IntegerField(
        default=None,
        null=True,
        blank=True,
        verbose_name=_('dagen op voorhand bestellen'),
        help_text=(
            'Minimum dagen op voorhand bestellen voor het uur ingesteld op '
            'de winkel. (0 is dezelfde dag, >=1 is dat aantal dagen voor het '
            'bepaalde uur.) Dit leeg laten betekent dat deze instelling '
            'overgenomen wordt van het type etenswaar.'
        )
    )
    preorder_disabled = models.BooleanField(
        default=False,
        verbose_name=_('voorhand bestelling uitschakelen'),
        help_text=(
            'Of de mogelijkheid om op voorhand te bestellen specifiek voor '
            'dit etenswaar is uitgeschakeld.'
        )
    )

    @cached_property
    def _safedelete_policy(self):
        """Food can be deleted if no active OrderedFood use them.

        Returns:
            SOFT_DELETE if still used, HARD_DELETE otherwise.
        """
        from customers.models import OrderedFood

        active_order = OrderedFood.objects.active_with(
            food=self
        ).exists()
        if active_order:
            return SOFT_DELETE
        return HARD_DELETE

    @cached_property
    def store(self):
        return self.menu.store

    @cached_property
    def ingredients_count(self):
        try:
            self._prefetched_objects_cache[self.ingredients.prefetch_cache_name]
            # Ingredients is prefetched
            return len(self.ingredients.all())
        except (AttributeError, KeyError):
            # Not prefetched
            return self.ingredients.count()

    @cached_property
    def ingredientgroups_count(self):
        try:
            self._prefetched_objects_cache[self.ingredientgroups.prefetch_cache_name]
            # Ingredientgroups is prefetched
            return len(self.ingredientgroups.all())
        except (AttributeError, KeyError):
            # Not prefetched
            return self.ingredientgroups.count()

    @cached_property
    def has_ingredients(self):
        return self.ingredients_count > 0 or self.ingredientgroups_count > 0

    @cached_property
    def selected_ingredients(self):
        return self.ingredients.filter(
            selected=True
        )

    @cached_property
    def nonempty_ingredientgroups(self):
        return self.ingredientgroups.filter(
            # Do not include empty groups
            ingredients__isnull=False
        ).prefetch_related(
            'ingredients'
        ).distinct()

    @cached_property
    def all_ingredients(self):
        relations = self.ingredientrelations.select_related(
            'ingredient'
        ).all()

        deselected_ingredients = []
        selected_ingredients = []

        for relation in relations:
            if relation.selected:
                selected_ingredients.append(relation.ingredient)
            else:
                deselected_ingredients.append(relation.ingredient)

        for ingredientgroup in self.nonempty_ingredientgroups:
            group_ingredients = ingredientgroup.ingredients.all()
            for ingredient in group_ingredients:
                if ingredient not in selected_ingredients \
                        and ingredient not in deselected_ingredients:
                    deselected_ingredients.append(ingredient)

        return selected_ingredients, deselected_ingredients

    @cached_property
    def all_ingredientgroups(self):
        ingredientgroups = self.ingredientgroups.filter(
            # Do not include empty groups
            ingredients__isnull=False
        ).prefetch_related(
            'ingredients'
        ).distinct()

        ingredients = self.ingredients.all().select_related(
            'group'
        )

        ingredientgroups_added = []
        for ingredient in ingredients:
            if ingredient.group not in ingredientgroups \
                    and ingredient.group not in ingredientgroups_added:
                ingredientgroups_added.append(
                    ingredient.group
                )

        all_ingredientgroups = []
        all_ingredientgroups.extend(ingredientgroups)
        all_ingredientgroups.extend(ingredientgroups_added)

        return all_ingredientgroups

    @cached_property
    def quantity(self):
        from .quantity import Quantity

        try:
            return Quantity.objects.get(
                foodtype_id=self.foodtype_id,
                store_id=self.store.id
            )
        except Quantity.DoesNotExist:
            return None

    @cached_property
    def inherited_wait(self):
        return self.wait \
            if self.wait is not None \
            else self.foodtype.inherited_wait

    @property
    def preorder_settings(self):
        """Returns the preorder settings and include the inherited FoodType's settings."""

        if self.preorder_disabled:
            return None, None

        preorder_days = self.preorder_days \
            if self.preorder_days is not None \
            else self.foodtype.preorder_days
        preorder_time = self.preorder_time \
            if self.preorder_time is not None \
            else self.foodtype.preorder_time

        return preorder_days, preorder_time

    def get_cost_display(self):
        return str(
            (
                Decimal(self.cost) / Decimal(100)
            ).quantize(
                Decimal('0.01')
            )
        ).replace('.', ',')

    def get_ingredients_display(self):

        def to_representation(ingredient):
            return ingredient.name

        return uggettext_summation(
            self.ingredients.all(),
            to_representation
        ).capitalize() + '.'

    def update_typical(self):
        ingredientgroups = self.ingredientgroups.all()
        ingredientrelations = self.ingredientrelations.select_related(
            'ingredient__group'
        ).all()

        for ingredientrelation in ingredientrelations:
            ingredient = ingredientrelation.ingredient
            # Musn't the ingredient be selected before it can be typical?
            if ingredient.group not in ingredientgroups:
                if not ingredientrelation.typical:
                    ingredientrelation.typical = True
                    ingredientrelation.save()
            elif ingredientrelation.typical:
                ingredientrelation.typical = False
                ingredientrelation.save()

    def is_orderable(self, dt, now=None):
        """
        Check whether this food can be ordered for the given day.
        This does not check whether the Store.wait has been exceeded!
        """
        preorder_days, preorder_time = self.preorder_settings

        if preorder_days is None or preorder_time is None:
            return True

        now = timezone.now() if now is None else now
        # Amount of days needed to order in advance
        # (add 1 if it isn't before the preorder_time)
        preorder_days = preorder_days + (
            1 if now.time() > preorder_time else 0
        )

        # Calculate the amount of days between dt and now
        difference_day = (dt - now).days
        difference_day += (
            1
            if dt.time() < now.time() and
            (now + (dt - now)).day != now.day
            else 0
        )
        return difference_day >= preorder_days

    def is_valid_amount(self, amount, raise_exception=True):
        return self.foodtype.is_valid_amount(
            amount=amount,
            quantity=self.quantity,
            raise_exception=raise_exception
        )

    def clean_amount(self):
        self.foodtype.is_valid_amount(self.amount)

    def save(self, *args, **kwargs):
        self.full_clean()

        super(Food, self).save(*args, **kwargs)

        if self.deleted:
            self.delete()

    def check_ingredients(self, ingredients):
        """
        Check whether the given ingredients can be made into an OrderedFood.
        """

        ingredientgroups = {}
        for ingredient in ingredients:
            group = ingredient.group
            amount = 1
            if group.id in ingredientgroups:
                amount += ingredientgroups[group.id]
            if group.maximum > 0 and amount > group.maximum:
                raise IngredientGroupMaxExceeded()
            ingredientgroups[group.id] = amount

        from .ingredient import Ingredient

        allowed_ingredients = Ingredient.objects.filter(
            Q(food__pk=self.pk) | Q(group__food__pk=self.pk)
        ).distinct()

        valid_ingredients = all(
            ingredient in allowed_ingredients for ingredient in ingredients
        )
        if not valid_ingredients:
            raise LinkingError(
                _('Ingrediënten zijn niet toegelaten voor het gegeven etenswaar.')
            )

        original_ingredients = self.ingredients.all()

        for ingredient in original_ingredients:
            group = ingredient.group
            if group.minimum > 0:
                if group.id not in ingredientgroups:
                    raise IngredientGroupsMinimumNotMet()
                amount = ingredientgroups[group.id]

                if amount < group.minimum:
                    raise IngredientGroupsMinimumNotMet()

    @staticmethod
    def changed_ingredients(sender, instance, action=None, **kwargs):
        from .ingredient_group import IngredientGroup
        from .ingredient import Ingredient
        from .ingredient_relation import IngredientRelation

        if action is None or len(action) > 4 and action[:4] == 'post':
            if isinstance(instance, Food):
                instance.update_typical()
            elif instance.__class__ in [Ingredient, IngredientGroup]:
                for food in instance.food_set.all():
                    food.update_typical()
            elif isinstance(instance, IngredientRelation):
                instance.food.update_typical()

    @classmethod
    def check_ingredientgroups(cls, action, instance, pk_set, **kwargs):
        if len(action) > 4 and action[:4] == 'post':
            groups = instance.ingredientgroups.filter(
                ~Q(store_id=instance.menu.store_id)
            )
            if groups.exists():
                raise LinkingError(
                    'The food and its ingredientgroups need to belong to the same store.'
                )

    @classmethod
    def from_excel(cls, store, file):
        workbook = load_workbook(
            filename=file,
            read_only=True
        )

        if 'Food' not in workbook:
            raise LunchbreakException(
                _('The worksheet "Food" could not be found. Please use our template.')
            )

        from .food_type import FoodType
        from .menu import Menu

        worksheet = workbook['Food']
        mapping = [
            {
                'field_name': 'name',
            },
            {
                'field_name': 'description',
            },
            {
                'field_name': 'menu',
                'instance': {
                    'model': Menu,
                    'create': True,
                    'field_name': 'name'
                }
            },
            {
                'field_name': 'cost',
            },
            {
                'field_name': 'foodtype',
                'instance': {
                    'model': FoodType,
                    'field_name': 'name',
                    'store': False
                }
            },
            {
                'field_name': 'preorder_days',
            },
            {
                'field_name': 'priority',
            },
        ]
        mapping_length = len(mapping)

        food_list = []
        created_relations = []
        skip = True
        for row in worksheet.rows:
            # Skip headers
            if skip:
                skip = False
                continue

            kwargs = {}
            exclude = []

            for cell in row:
                if not isinstance(cell.column, int):
                    continue

                i = cell.column - 1
                if i >= mapping_length:
                    continue
                info = mapping[i]
                value = cell.value
                if 'instance' in info:
                    instance = info['instance']
                    create = instance.get('create', False)
                    model = instance['model']

                    exclude.append(info['field_name'])
                    where = {
                        instance['field_name']: cell.value
                    }
                    if instance.get('store', True):
                        where['store'] = store

                    if create:
                        value, created = model.objects.get_or_create(
                            **where
                        )
                        if created:
                            created_relations.append(value)
                    else:
                        value = model.objects.get(
                            **where
                        )

                kwargs[info['field_name']] = value

            food = Food(
                **kwargs
            )
            try:
                food.clean_fields(exclude=exclude)
            except LunchbreakException:
                for relation in created_relations:
                    relation.delete()
                raise LunchbreakException(
                    _('Could not import row %(row)d.') % {
                        'row': cell.row
                    }
                )

            food_list.append(food)

        try:
            cls.objects.bulk_create(food_list)
        except DatabaseError:
            for relation in created_relations:
                relation.delete()