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)
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
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)
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()