def search(request): search_query = request.GET.get('query', None) page = request.GET.get('page', 1) # Search if search_query: index.AutocompleteField('title') search_results = ArticlePage.objects.search(search_query, fields=['title']) query = Query.get(search_query) # Record hit query.add_hit() else: search_results = Page.objects.none() # Pagination paginator = Paginator(search_results, 10) try: search_results = paginator.page(page) except PageNotAnInteger: search_results = paginator.page(1) except EmptyPage: search_results = paginator.page(paginator.num_pages) return render(request, 'search/search.html', { 'search_query': search_query, 'search_results': search_results, })
class Book(index.Indexed, models.Model): title = models.CharField(max_length=255) authors = models.ManyToManyField(Author, related_name="books") publication_date = models.DateField() number_of_pages = models.IntegerField() tags = TaggableManager() search_fields = [ index.SearchField("title", partial_match=True, boost=2.0), index.AutocompleteField("title"), index.FilterField("title"), index.FilterField("authors"), index.RelatedFields("authors", Author.search_fields), index.FilterField("publication_date"), index.FilterField("number_of_pages"), index.RelatedFields( "tags", [ index.SearchField("name"), index.FilterField("slug"), ], ), index.FilterField("tags"), ] @classmethod def get_indexed_objects(cls): indexed_objects = super(Book, cls).get_indexed_objects() # Don't index books using Book class that they have a more specific type if cls is Book: indexed_objects = indexed_objects.exclude( id__in=Novel.objects.values_list("book_ptr_id", flat=True)) indexed_objects = indexed_objects.exclude( id__in=ProgrammingGuide.objects.values_list("book_ptr_id", flat=True)) # Exclude Books that have the title "Don't index me!" indexed_objects = indexed_objects.exclude(title="Don't index me!") return indexed_objects def get_indexed_instance(self): # Check if this object is a Novel or ProgrammingGuide and return the specific object novel = Novel.objects.filter(book_ptr_id=self.id).first() programming_guide = ProgrammingGuide.objects.filter( book_ptr_id=self.id).first() # Return the novel/programming guide object if there is one, otherwise return self return novel or programming_guide or self def __str__(self): return self.title
class Author(index.Indexed, models.Model): name = models.CharField(max_length=255) date_of_birth = models.DateField(null=True) search_fields = [ index.SearchField('name'), index.AutocompleteField('name'), index.FilterField('date_of_birth'), ] def __str__(self): return self.name
class ExternalAttachment(DisplayUrlMixin, CollectionMember, index.Indexed, models.Model): """An externally hosted link or file that can be associated with a Page.""" # replicate the same fields as Document but with URL instead of file; see: # https://github.com/wagtail/wagtail/blob/master/wagtail/documents/models.py#L27-L37 title = models.CharField(max_length=255) author = models.CharField(max_length=255, blank=True, help_text="Citation or list of authors") created_at = models.DateTimeField(auto_now_add=True, editable=False) tags = TaggableManager(blank=True) # adapted from AbstractDocument but with URL instead; see: # https://github.com/wagtail/wagtail/blob/master/wagtail/documents/models.py#L47-L56 search_fields = CollectionMember.search_fields + [ index.SearchField("title", partial_match=True, boost=10), index.AutocompleteField("title"), index.FilterField("title"), index.SearchField("url", partial_match=True), index.RelatedFields( "tags", [ index.SearchField("name", partial_match=True, boost=10), index.AutocompleteField("name"), ], ), ] # same QS/manager and form fields as Attachment objects = DocumentQuerySet.as_manager() admin_form_fields = ("title", "author", "url", "collection", "tags") def __str__(self): """Attachment title, author(s) if present, and URL.""" parts = [self.title] if self.author: parts.append(", %s" % self.author) parts.append(" (%s)" % self.display_url) return "".join(parts)
class CategoryProxy(index.Indexed, get_model("catalogue", "Category")): search_fields = [ index.SearchField("name", partial_match=True, boost=2), index.AutocompleteField("name"), index.SearchField("description"), index.SearchField("full_name"), index.FilterField("full_slug"), index.FilterField("slug"), index.FilterField("get_absolute_url"), ] class Meta: proxy = True
class Definition(index.Indexed, ClusterableModel): glossary = models.ForeignKey(Glossary, on_delete=models.CASCADE, related_name="definitions") definition = models.TextField(blank=True) search_fields = [ index.FilterField("glossary"), index.RelatedFields("glossary", [ index.FilterField("locale"), ]), index.RelatedFields("terms", [ index.SearchField("term"), index.AutocompleteField("term"), ]), ] panels = [ FieldPanel("glossary"), InlinePanel("terms", label="Terms"), FieldPanel("definition"), ] def __str__(self): terms = self.terms.all().values_list("term", flat=True)[:5] return f"{', '.join(terms)}"
class AbstractDocument(CollectionMember, index.Indexed, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) file = EncryptedFileField(upload_to='documents', verbose_name=_('file')) created_at = models.DateTimeField(verbose_name=_('created at'), auto_now_add=True) uploaded_by_user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('uploaded by user'), null=True, blank=True, editable=False, on_delete=models.SET_NULL) tags = TaggableManager(help_text=None, blank=True, verbose_name=_('tags')) file_size = models.PositiveIntegerField(null=True, editable=False) objects = DocumentQuerySet.as_manager() search_fields = CollectionMember.search_fields + [ index.SearchField('title', partial_match=True, boost=10), index.AutocompleteField('title'), index.FilterField('title'), index.RelatedFields('tags', [ index.SearchField('name', partial_match=True, boost=10), index.AutocompleteField('name'), ]), index.FilterField('uploaded_by_user'), ] def get_file_size(self): if self.file_size is None: try: self.file_size = self.file.size except (SystemExit, KeyboardInterrupt): raise except Exception: # File doesn't exist return self.save(update_fields=['file_size']) return self.file_size def __str__(self): return self.title @property def filename(self): return os.path.basename(self.file.name) @property def file_extension(self): return os.path.splitext(self.filename)[1][1:] @property def url(self): return reverse('wagtaildocs_serve', args=[self.id, self.filename]) def get_usage(self): return get_object_usage(self) @property def usage_url(self): return reverse('wagtaildocs:document_usage', args=(self.id, )) def is_editable_by_user(self, user): from wagtail.documents.permissions import permission_policy return permission_policy.user_has_permission_for_instance( user, 'change', self) class Meta: abstract = True verbose_name = _('document')
class Author(I18nPage): """A page that describes an author.""" template = "cms/preview/author.html" GENDER_UNKNOWN = "U" GENDER_MALE = "M" GENDER_FEMALE = "F" GENDER_CHOICES = ( (GENDER_UNKNOWN, _(TXT["gender.unknown"])), (GENDER_MALE, _(TXT["gender.male"])), (GENDER_FEMALE, _(TXT["gender.female"])), ) title_image = models.ForeignKey( "ImageMedia", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", verbose_name=_(TXT["author.title_image"]), help_text=_(TXT["author.title_image.help"]), ) sex = models.CharField( max_length=1, choices=GENDER_CHOICES, default=GENDER_UNKNOWN, verbose_name=_(TXT["author.gender"]), ) # TODO: rename to gender date_of_birth_year = models.PositiveSmallIntegerField( null=True, blank=True, validators=[MinValueValidator(0), MaxValueValidator(9999)], verbose_name=_(TXT["author.date_of_birth_year"]), help_text=_(TXT["author.date_of_birth_year.help"]), ) date_of_birth_month = models.PositiveSmallIntegerField( choices=list(dates.MONTHS.items()), null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(12)], verbose_name=_(TXT["author.date_of_birth_month"]), help_text=_(TXT["author.date_of_birth_month.help"]), ) date_of_birth_day = models.PositiveSmallIntegerField( null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(31)], verbose_name=_(TXT["author.date_of_birth_day"]), help_text=_(TXT["author.date_of_birth_day.help"]), ) place_of_birth = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["author.place_of_birth"]), help_text=_(TXT["author.place_of_birth.help"]), ) place_of_birth_de = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["author.place_of_birth"]), help_text=_(TXT["author.place_of_birth_de.help"]), ) place_of_birth_cs = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["author.place_of_birth"]), help_text=_(TXT["author.place_of_birth_cs.help"]), ) i18n_place_of_birth = TranslatedField.named("place_of_birth", True) date_of_death_year = models.PositiveSmallIntegerField( null=True, blank=True, validators=[MinValueValidator(0), MaxValueValidator(9999)], verbose_name=_(TXT["author.date_of_death_year"]), help_text=_(TXT["author.date_of_death_year.help"]), ) date_of_death_month = models.PositiveSmallIntegerField( choices=list(dates.MONTHS.items()), null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(12)], verbose_name=_(TXT["author.date_of_death_month"]), help_text=_(TXT["author.date_of_death_month.help"]), ) date_of_death_day = models.PositiveSmallIntegerField( null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(31)], verbose_name=_(TXT["author.date_of_death_day"]), help_text=_(TXT["author.date_of_death_day.help"]), ) place_of_death = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["author.place_of_death"]), help_text=_(TXT["author.place_of_death.help"]), ) place_of_death_de = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["author.place_of_death"]), help_text=_(TXT["author.place_of_death_de.help"]), ) place_of_death_cs = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["author.place_of_death"]), help_text=_(TXT["author.place_of_death_cs.help"]), ) i18n_place_of_death = TranslatedField.named("place_of_death", True) language_tags = ParentalManyToManyField( "LanguageTag", db_table=DB_TABLE_PREFIX + "author_tag_language", related_name="authors", blank=True, verbose_name=_(TXT["author.language.plural"]), help_text=_(TXT["author.language.help"]), ) genre_tags = ParentalManyToManyField( "GenreTag", db_table=DB_TABLE_PREFIX + "author_tag_genre", related_name="authors", blank=True, verbose_name=_(TXT["author.genre.plural"]), help_text=_(TXT["author.genre.help"]), ) literary_period_tags = ParentalManyToManyField( "PeriodTag", db_table=DB_TABLE_PREFIX + "author_tag_literary_period", related_name="authors", blank=True, verbose_name=_(TXT["author.literary_period.plural"]), help_text=_(TXT["author.literary_period.help"]), ) @property def born(self): """Return the year of birth as a datetime object.""" if (self.date_of_birth_year and self.date_of_birth_month and self.date_of_birth_day): return datetime.date( self.date_of_birth_year, self.date_of_birth_month, self.date_of_birth_day, ) @property def died(self): """Return the year of death as a datetime object.""" if (self.date_of_death_year and self.date_of_death_month and self.date_of_death_day): return datetime.date( self.date_of_death_year, self.date_of_death_month, self.date_of_death_day, ) @property def age(self): """Return the age of the author in years.""" if self.born and self.died: diff_year = self.died.year - self.born.year diff_remainder = (self.died.month, self.died.day) < ( self.born.month, self.born.day, ) return diff_year - diff_remainder parent_page_types = ["AuthorIndex"] search_fields = I18nPage.search_fields + [ index.RelatedFields( "names", [ index.SearchField("title"), index.AutocompleteField("title"), index.SearchField("first_name"), index.AutocompleteField("first_name"), index.SearchField("last_name"), index.AutocompleteField("last_name"), index.SearchField("birth_name"), index.AutocompleteField("birth_name"), index.FilterField("is_pseudonym"), ], ), index.FilterField("sex"), index.FilterField("born"), index.FilterField("died"), index.FilterField("age"), ] api_fields = I18nPage.api_fields + [ "sex", "genre_tags", "genretag_id", "language_tags", "literary_period_tags", ] general_panels = [ ImageChooserPanel("title_image"), InlinePanel( "names", panels=[ FieldPanel("is_pseudonym"), FieldPanelTabs( children=[ MultiFieldPanel( children=[ FieldPanel("title"), FieldPanel("first_name"), FieldPanel("last_name"), FieldPanel("birth_name"), ], heading=_(TXT["heading.en"]), classname="collapsible", ), MultiFieldPanel( children=[ FieldPanel("title_de"), FieldPanel("first_name_de"), FieldPanel("last_name_de"), FieldPanel("birth_name_de"), ], heading=_(TXT["heading.de"]), ), MultiFieldPanel( children=[ FieldPanel("title_cs"), FieldPanel("first_name_cs"), FieldPanel("last_name_cs"), FieldPanel("birth_name_cs"), ], heading=_(TXT["heading.cs"]), ), ], heading=_(TXT["author.name"]), ), ], label=_(TXT["author.name.plural"]), min_num=1, help_text=_(TXT["author.name.help"]), ), FieldPanel("sex"), MultiFieldPanel( children=[ FieldPanel("date_of_birth_day"), FieldPanel("date_of_birth_month"), FieldPanel("date_of_birth_year"), FieldPanelTabs( children=[ FieldPanelTab("place_of_birth", heading=_(TXT["language.en"])), FieldPanelTab("place_of_birth_de", heading=_(TXT["language.de"])), FieldPanelTab("place_of_birth_cs", heading=_(TXT["language.cs"])), ], heading=_(TXT["author.place_of_birth"]), ), ], heading=_(TXT["author.date_of_birth"]), ), MultiFieldPanel( children=[ FieldPanel("date_of_death_day"), FieldPanel("date_of_death_month"), FieldPanel("date_of_death_year"), FieldPanelTabs( children=[ FieldPanelTab("place_of_death", heading=_(TXT["language.en"])), FieldPanelTab("place_of_death_de", heading=_(TXT["language.de"])), FieldPanelTab("place_of_death_cs", heading=_(TXT["language.cs"])), ], heading=_(TXT["author.place_of_death"]), ), ], heading=_(TXT["author.date_of_death"]), ), MultiFieldPanel( heading=_(TXT["tag.plural"]), children=[ FieldPanel( field_name="language_tags", widget=autocomplete.ModelSelect2Multiple( url="autocomplete-language"), ), FieldPanel( field_name="genre_tags", widget=autocomplete.ModelSelect2Multiple( url="autocomplete-genre"), ), FieldPanel( field_name="literary_period_tags", widget=autocomplete.ModelSelect2Multiple( url="autocomplete-literary-period"), ), ], ), ] edit_handler = TabbedInterface([ ObjectList(general_panels, heading=_(TXT["heading.general"])), ObjectList(I18nPage.meta_panels, heading=_(TXT["heading.meta"])), ]) @property def full_name_title(self): """Return the full name of the author including her birth name to be used in titles.""" return self.names.first().full_name_title(self.sex) @property def formatted_date_of_birth(self): """Format date of birth in human readable string.""" return format_date(self.date_of_birth_year, self.date_of_birth_month, self.date_of_birth_day) @property def formatted_date_of_death(self): """Format date of death in human readable string.""" return format_date(self.date_of_death_year, self.date_of_death_month, self.date_of_death_day) def full_clean(self, *args, **kwargs): """Add autogenerated values for non-editable required fields.""" name = self.names.first() if name: self.title = name.full_name() self.title_de = name.full_name_de() self.title_cs = name.full_name_cs() base_slug = text.slugify(name.last_name, allow_unicode=True) self.slug = self._get_autogenerated_slug(base_slug) super(Author, self).full_clean(*args, **kwargs) def clean(self): """Validate date components of input.""" super(Author, self).clean() validate_date(self.date_of_birth_year, self.date_of_birth_month, self.date_of_birth_day) validate_date(self.date_of_death_year, self.date_of_death_month, self.date_of_death_day) def get_context(self, request, *args, **kwargs): """Add furthor context information to preview requests.""" context = super(Author, self).get_context(request, *args, **kwargs) # add linked memorials if request.is_preview: context["memorials"] = Memorial.objects.filter( remembered_authors=self) return context class Meta: db_table = DB_TABLE_PREFIX + "author" verbose_name = _(TXT["author"]) verbose_name_plural = _(TXT["author.plural"]) class JSONAPIMeta: resource_name = "authors"
class I18nPage(Page): """ An abstract base page class that supports translated content. The class should be used for all page types of the CMS. Overrides Page.save and Page.save_revision methods to make sure multilingual content is handled the same as the default fields. """ objects = TranslatedTitlePageManager() ORIGINAL_LANGUAGE_ENGLISH = "en" ORIGINAL_LANGUAGE_GERMAN = "de" ORIGINAL_LANGUAGE_CZECH = "cs" ORIGINAL_LANGUAGE = ( (ORIGINAL_LANGUAGE_ENGLISH, _(TXT["language.en"])), (ORIGINAL_LANGUAGE_GERMAN, _(TXT["language.de"])), (ORIGINAL_LANGUAGE_CZECH, _(TXT["language.cs"])), ) RICH_TEXT_FEATURES = ["bold", "italic", "strikethrough", "link"] template = "cms/preview/blog.html" title_de = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["page.title_de"]), help_text=_(TXT["page.title_de.help"]), ) title_cs = models.CharField( max_length=255, blank=True, verbose_name=_(TXT["page.title_cs"]), help_text=_(TXT["page.title_cs.help"]), ) i18n_title = TranslatedField.named("title", True) draft_title_de = models.CharField( max_length=255, blank=True, editable=False, verbose_name=_(TXT["page.draft_title_de"]), help_text=_(TXT["page.draft_title_de.help"]), ) draft_title_cs = models.CharField( max_length=255, blank=True, editable=False, verbose_name=_(TXT["page.draft_title_cs"]), help_text=_(TXT["page.draft_title_cs.help"]), ) i18n_draft_title = TranslatedField.named("draft_title", True) editor = models.CharField( max_length=2048, verbose_name=_(TXT["page.editor"]), help_text=_(TXT["page.editor.help"]), ) original_language = models.CharField( max_length=3, choices=ORIGINAL_LANGUAGE, default=ORIGINAL_LANGUAGE_GERMAN, verbose_name=_(TXT["page.original_language"]), help_text=_(TXT["page.original_language.help"]), ) temporary_redirect = models.CharField( max_length=250, blank=True, default="", verbose_name=_(TXT["page.temporary_redirect"]), help_text=_(TXT["page.temporary_redirect.help"]), ) is_creatable = False search_fields = Page.search_fields + [ index.SearchField("title_de", partial_match=True, boost=2), index.AutocompleteField("title_de"), index.SearchField("title_cs", partial_match=True, boost=2), index.AutocompleteField("title_cs"), index.FilterField("title_de"), index.FilterField("title_cs"), ] api_fields = [ APIField("title_de"), APIField("title_cs"), APIField("editor"), ] english_panels = [FieldPanel("title", classname="full title")] german_panels = [FieldPanel("title_de", classname="full title")] czech_panels = [FieldPanel("title_cs", classname="full title")] promote_panels = Page.promote_panels + [FieldPanel("temporary_redirect")] meta_panels = [ FieldPanel("owner"), FieldPanel("editor"), FieldPanel("original_language"), ] edit_handler = TabbedInterface([ ObjectList(english_panels, heading=_(TXT["heading.en"])), ObjectList(german_panels, heading=_(TXT["heading.de"])), ObjectList(czech_panels, heading=_(TXT["heading.cs"])), ObjectList(promote_panels, heading=_(TXT["heading.promote"])), ObjectList(meta_panels, heading=_(TXT["heading.meta"])), ]) @cached_property def is_restricted(self): """Return True if this page is restricted to the public in any way.""" return (self.get_view_restrictions().exclude( restriction_type=BaseViewRestriction.NONE).exists()) def serve(self, request, *args, **kwargs): """Return a redirect of the temporary_redirect property is set.""" # if self.temporary_redirect: # return redirect(self.temporary_redirect, permanent=False) return super(I18nPage, self).serve(request, *args, **kwargs) def get_admin_display_title(self): """Return title to be displayed in the admins UI.""" return self.i18n_draft_title or self.i18n_title def full_clean(self, *args, **kwargs): """Set the translated draft titles according the translated title fields.""" if not self.draft_title_de: self.draft_title_de = self.title_de if not self.draft_title_cs: self.draft_title_cs = self.title_cs super(I18nPage, self).full_clean(*args, **kwargs) def save_revision( self, user=None, submitted_for_moderation=False, approved_go_live_at=None, changed=True, ): """Add applications and translation specific fields to the revision of the page.""" # TODO: Add explicit read-only permission to support access to admin backend if user.groups.filter(name='READONLY').exists(): raise PermissionDenied self.full_clean() # Create revision revision = self.revisions.create( content_json=self.to_json(), user=user, submitted_for_moderation=submitted_for_moderation, approved_go_live_at=approved_go_live_at, ) update_fields = [] self.latest_revision_created_at = revision.created_at update_fields.append("latest_revision_created_at") self.draft_title = self.title self.draft_title_de = self.title_de self.draft_title_cs = self.title_cs update_fields.append("draft_title") update_fields.append("draft_title_de") update_fields.append("draft_title_cs") if changed: self.has_unpublished_changes = True update_fields.append("has_unpublished_changes") if update_fields: self.save(update_fields=update_fields) # Log LOGGER.info( f'Page edited: "{self.title}" id={self.pk} revision_id={revision.id}' ) if submitted_for_moderation: LOGGER.info(f"""" Page submitted for moderation: \"{self.title}\" id={self.pk} revision_id={revision.id} """) return revision def __str__(self): return str(self.i18n_title)
class AbstractImage(CollectionMember, index.Indexed, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) file = models.ImageField(verbose_name=_('file'), upload_to=get_upload_to, width_field='width', height_field='height') width = models.IntegerField(verbose_name=_('width'), editable=False) height = models.IntegerField(verbose_name=_('height'), editable=False) created_at = models.DateTimeField(verbose_name=_('created at'), auto_now_add=True, db_index=True) uploaded_by_user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('uploaded by user'), null=True, blank=True, editable=False, on_delete=models.SET_NULL) tags = TaggableManager(help_text=None, blank=True, verbose_name=_('tags')) focal_point_x = models.PositiveIntegerField(null=True, blank=True) focal_point_y = models.PositiveIntegerField(null=True, blank=True) focal_point_width = models.PositiveIntegerField(null=True, blank=True) focal_point_height = models.PositiveIntegerField(null=True, blank=True) file_size = models.PositiveIntegerField(null=True, editable=False) # A SHA-1 hash of the file contents file_hash = models.CharField(max_length=40, blank=True, editable=False) objects = ImageQuerySet.as_manager() def is_stored_locally(self): """ Returns True if the image is hosted on the local filesystem """ try: self.file.path return True except NotImplementedError: return False def get_file_size(self): if self.file_size is None: try: self.file_size = self.file.size except Exception as e: # File not found # # Have to catch everything, because the exception # depends on the file subclass, and therefore the # storage being used. raise SourceImageIOError(str(e)) self.save(update_fields=['file_size']) return self.file_size def _set_file_hash(self, file_contents): self.file_hash = hashlib.sha1(file_contents).hexdigest() def get_file_hash(self): if self.file_hash == '': with self.open_file() as f: self._set_file_hash(f.read()) self.save(update_fields=['file_hash']) return self.file_hash def get_upload_to(self, filename): folder_name = 'original_images' filename = self.file.field.storage.get_valid_name(filename) # do a unidecode in the filename and then # replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding filename = "".join( (i if ord(i) < 128 else '_') for i in unidecode(filename)) # Truncate filename so it fits in the 100 character limit # https://code.djangoproject.com/ticket/9893 full_path = os.path.join(folder_name, filename) if len(full_path) >= 95: chars_to_trim = len(full_path) - 94 prefix, extension = os.path.splitext(filename) filename = prefix[:-chars_to_trim] + extension full_path = os.path.join(folder_name, filename) return full_path def get_usage(self): return get_object_usage(self) @property def usage_url(self): return reverse('wagtailimages:image_usage', args=(self.id, )) search_fields = CollectionMember.search_fields + [ index.SearchField('title', partial_match=True, boost=10), index.AutocompleteField('title'), index.FilterField('title'), index.RelatedFields('tags', [ index.SearchField('name', partial_match=True, boost=10), index.AutocompleteField('name'), ]), index.FilterField('uploaded_by_user'), ] def __str__(self): return self.title @contextmanager def open_file(self): # Open file if it is closed close_file = False try: image_file = self.file if self.file.closed: # Reopen the file if self.is_stored_locally(): self.file.open('rb') else: # Some external storage backends don't allow reopening # the file. Get a fresh file instance. #1397 storage = self._meta.get_field('file').storage image_file = storage.open(self.file.name, 'rb') close_file = True except IOError as e: # re-throw this as a SourceImageIOError so that calling code can distinguish # these from IOErrors elsewhere in the process raise SourceImageIOError(str(e)) # Seek to beginning image_file.seek(0) try: yield image_file finally: if close_file: image_file.close() @contextmanager def get_willow_image(self): with self.open_file() as image_file: yield WillowImage.open(image_file) def get_rect(self): return Rect(0, 0, self.width, self.height) def get_focal_point(self): if self.focal_point_x is not None and \ self.focal_point_y is not None and \ self.focal_point_width is not None and \ self.focal_point_height is not None: return Rect.from_point( self.focal_point_x, self.focal_point_y, self.focal_point_width, self.focal_point_height, ) def has_focal_point(self): return self.get_focal_point() is not None def set_focal_point(self, rect): if rect is not None: self.focal_point_x = rect.centroid_x self.focal_point_y = rect.centroid_y self.focal_point_width = rect.width self.focal_point_height = rect.height else: self.focal_point_x = None self.focal_point_y = None self.focal_point_width = None self.focal_point_height = None def get_suggested_focal_point(self): with self.get_willow_image() as willow: faces = willow.detect_faces() if faces: # Create a bounding box around all faces left = min(face[0] for face in faces) top = min(face[1] for face in faces) right = max(face[2] for face in faces) bottom = max(face[3] for face in faces) focal_point = Rect(left, top, right, bottom) else: features = willow.detect_features() if features: # Create a bounding box around all features left = min(feature[0] for feature in features) top = min(feature[1] for feature in features) right = max(feature[0] for feature in features) bottom = max(feature[1] for feature in features) focal_point = Rect(left, top, right, bottom) else: return None # Add 20% to width and height and give it a minimum size x, y = focal_point.centroid width, height = focal_point.size width *= 1.20 height *= 1.20 width = max(width, 100) height = max(height, 100) return Rect.from_point(x, y, width, height) @classmethod def get_rendition_model(cls): """ Get the Rendition model for this Image model """ return cls.renditions.rel.related_model def get_rendition(self, filter): if isinstance(filter, str): filter = Filter(spec=filter) cache_key = filter.get_cache_key(self) Rendition = self.get_rendition_model() try: rendition = self.renditions.get( filter_spec=filter.spec, focal_point_key=cache_key, ) except Rendition.DoesNotExist: # Generate the rendition image generated_image = filter.run(self, BytesIO()) # Generate filename input_filename = os.path.basename(self.file.name) input_filename_without_extension, input_extension = os.path.splitext( input_filename) # A mapping of image formats to extensions FORMAT_EXTENSIONS = { 'jpeg': '.jpg', 'png': '.png', 'gif': '.gif', } output_extension = filter.spec.replace( '|', '.') + FORMAT_EXTENSIONS[generated_image.format_name] if cache_key: output_extension = cache_key + '.' + output_extension # Truncate filename to prevent it going over 60 chars output_filename_without_extension = input_filename_without_extension[:( 59 - len(output_extension))] output_filename = output_filename_without_extension + '.' + output_extension rendition, created = self.renditions.get_or_create( filter_spec=filter.spec, focal_point_key=cache_key, defaults={ 'file': File(generated_image.f, name=output_filename) }) return rendition def is_portrait(self): return (self.width < self.height) def is_landscape(self): return (self.height < self.width) @property def filename(self): return os.path.basename(self.file.name) @property def default_alt_text(self): # by default the alt text field (used in rich text insertion) is populated # from the title. Subclasses might provide a separate alt field, and # override this return self.title def is_editable_by_user(self, user): from wagtail.images.permissions import permission_policy return permission_policy.user_has_permission_for_instance( user, 'change', self) class Meta: abstract = True
class AbstractDocument(CollectionMember, index.Indexed, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) file = models.FileField(upload_to='documents', verbose_name=_('file')) created_at = models.DateTimeField(verbose_name=_('created at'), auto_now_add=True) uploaded_by_user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('uploaded by user'), null=True, blank=True, editable=False, on_delete=models.SET_NULL) tags = TaggableManager(help_text=None, blank=True, verbose_name=_('tags')) file_size = models.PositiveIntegerField(null=True, editable=False) # A SHA-1 hash of the file contents file_hash = models.CharField(max_length=40, blank=True, editable=False) objects = DocumentQuerySet.as_manager() search_fields = CollectionMember.search_fields + [ index.SearchField('title', partial_match=True, boost=10), index.AutocompleteField('title'), index.FilterField('title'), index.RelatedFields('tags', [ index.SearchField('name', partial_match=True, boost=10), index.AutocompleteField('name'), ]), index.FilterField('uploaded_by_user'), ] def is_stored_locally(self): """ Returns True if the image is hosted on the local filesystem """ try: self.file.path return True except NotImplementedError: return False @contextmanager def open_file(self): # Open file if it is closed close_file = False f = self.file if f.closed: # Reopen the file if self.is_stored_locally(): f.open('rb') else: # Some external storage backends don't allow reopening # the file. Get a fresh file instance. #1397 storage = self._meta.get_field('file').storage f = storage.open(f.name, 'rb') close_file = True # Seek to beginning f.seek(0) try: yield f finally: if close_file: f.close() def get_file_size(self): if self.file_size is None: try: self.file_size = self.file.size except Exception: # File doesn't exist return self.save(update_fields=['file_size']) return self.file_size def _set_file_hash(self, file_contents): self.file_hash = hashlib.sha1(file_contents).hexdigest() def get_file_hash(self): if self.file_hash == '': with self.open_file() as f: self._set_file_hash(f.read()) self.save(update_fields=['file_hash']) return self.file_hash def __str__(self): return self.title @property def filename(self): return os.path.basename(self.file.name) @property def file_extension(self): return os.path.splitext(self.filename)[1][1:] @property def url(self): if getattr(settings, 'WAGTAILDOCS_SERVE_METHOD', None) == 'direct': try: return self.file.url except NotImplementedError: # backend does not provide a url, so fall back on the serve view pass return reverse('wagtaildocs_serve', args=[self.id, self.filename]) def get_usage(self): return get_object_usage(self) @property def usage_url(self): return reverse('wagtaildocs:document_usage', args=(self.id, )) def is_editable_by_user(self, user): from wagtail.documents.permissions import permission_policy return permission_policy.user_has_permission_for_instance( user, 'change', self) class Meta: abstract = True verbose_name = _('document') verbose_name_plural = _('documents')
class AbstractDocument(CollectionMember, index.Indexed, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) file = models.FileField(upload_to='documents', verbose_name=_('file')) created_at = models.DateTimeField(verbose_name=_('created at'), auto_now_add=True) uploaded_by_user = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('uploaded by user'), null=True, blank=True, editable=False, on_delete=models.SET_NULL ) tags = TaggableManager(help_text=None, blank=True, verbose_name=_('tags')) file_size = models.PositiveIntegerField(null=True, editable=False) # A SHA-1 hash of the file contents file_hash = models.CharField(max_length=40, blank=True, editable=False) objects = DocumentQuerySet.as_manager() search_fields = CollectionMember.search_fields + [ index.SearchField('title', partial_match=True, boost=10), index.AutocompleteField('title'), index.FilterField('title'), index.RelatedFields('tags', [ index.SearchField('name', partial_match=True, boost=10), index.AutocompleteField('name'), ]), index.FilterField('uploaded_by_user'), ] def clean(self): """ Checks for WAGTAILDOCS_EXTENSIONS and validates the uploaded file based on allowed extensions that were specified. Warning : This doesn't always ensure that the uploaded file is valid as files can be renamed to have an extension no matter what data they contain. More info : https://docs.djangoproject.com/en/3.1/ref/validators/#fileextensionvalidator """ allowed_extensions = getattr(settings, "WAGTAILDOCS_EXTENSIONS", None) if allowed_extensions: validate = FileExtensionValidator(allowed_extensions) validate(self.file) def is_stored_locally(self): """ Returns True if the image is hosted on the local filesystem """ try: self.file.path return True except NotImplementedError: return False @contextmanager def open_file(self): # Open file if it is closed close_file = False f = self.file if f.closed: # Reopen the file if self.is_stored_locally(): f.open('rb') else: # Some external storage backends don't allow reopening # the file. Get a fresh file instance. #1397 storage = self._meta.get_field('file').storage f = storage.open(f.name, 'rb') close_file = True # Seek to beginning f.seek(0) try: yield f finally: if close_file: f.close() def get_file_size(self): if self.file_size is None: try: self.file_size = self.file.size except Exception: # File doesn't exist return self.save(update_fields=['file_size']) return self.file_size def _set_file_hash(self, file_contents): self.file_hash = hashlib.sha1(file_contents).hexdigest() def get_file_hash(self): if self.file_hash == '': with self.open_file() as f: self._set_file_hash(f.read()) self.save(update_fields=['file_hash']) return self.file_hash def __str__(self): return self.title @property def filename(self): return os.path.basename(self.file.name) @property def file_extension(self): return os.path.splitext(self.filename)[1][1:] @property def url(self): if getattr(settings, 'WAGTAILDOCS_SERVE_METHOD', None) == 'direct': try: return self.file.url except NotImplementedError: # backend does not provide a url, so fall back on the serve view pass return reverse('wagtaildocs_serve', args=[self.id, self.filename]) def get_usage(self): return get_object_usage(self) @property def usage_url(self): return reverse('wagtaildocs:document_usage', args=(self.id,)) def is_editable_by_user(self, user): from wagtail.documents.permissions import permission_policy return permission_policy.user_has_permission_for_instance(user, 'change', self) @property def content_type(self): content_types_lookup = getattr(settings, 'WAGTAILDOCS_CONTENT_TYPES', {}) return ( content_types_lookup.get(self.file_extension.lower()) or guess_type(self.filename)[0] or 'application/octet-stream' ) @property def content_disposition(self): inline_content_types = getattr( settings, 'WAGTAILDOCS_INLINE_CONTENT_TYPES', ['application/pdf'] ) if self.content_type in inline_content_types: return 'inline' else: return "attachment; filename={0}; filename*=UTF-8''{0}".format( urllib.parse.quote(self.filename) ) class Meta: abstract = True verbose_name = _('document') verbose_name_plural = _('documents')
class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Model): title = models.CharField(max_length=255, verbose_name=_("title")) file = models.ImageField( verbose_name=_("file"), upload_to=get_upload_to, width_field="width", height_field="height", ) width = models.IntegerField(verbose_name=_("width"), editable=False) height = models.IntegerField(verbose_name=_("height"), editable=False) created_at = models.DateTimeField(verbose_name=_("created at"), auto_now_add=True, db_index=True) uploaded_by_user = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_("uploaded by user"), null=True, blank=True, editable=False, on_delete=models.SET_NULL, ) tags = TaggableManager(help_text=None, blank=True, verbose_name=_("tags")) focal_point_x = models.PositiveIntegerField(null=True, blank=True) focal_point_y = models.PositiveIntegerField(null=True, blank=True) focal_point_width = models.PositiveIntegerField(null=True, blank=True) focal_point_height = models.PositiveIntegerField(null=True, blank=True) file_size = models.PositiveIntegerField(null=True, editable=False) # A SHA-1 hash of the file contents file_hash = models.CharField(max_length=40, blank=True, editable=False, db_index=True) objects = ImageQuerySet.as_manager() def _set_file_hash(self, file_contents): self.file_hash = hashlib.sha1(file_contents).hexdigest() def get_file_hash(self): if self.file_hash == "": with self.open_file() as f: self._set_file_hash(f.read()) self.save(update_fields=["file_hash"]) return self.file_hash def get_upload_to(self, filename): folder_name = "original_images" filename = self.file.field.storage.get_valid_name(filename) # convert the filename to simple ascii characters and then # replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding filename = "".join( (i if ord(i) < 128 else "_") for i in string_to_ascii(filename)) # Truncate filename so it fits in the 100 character limit # https://code.djangoproject.com/ticket/9893 full_path = os.path.join(folder_name, filename) if len(full_path) >= 95: chars_to_trim = len(full_path) - 94 prefix, extension = os.path.splitext(filename) filename = prefix[:-chars_to_trim] + extension full_path = os.path.join(folder_name, filename) return full_path def get_usage(self): return get_object_usage(self) @property def usage_url(self): return reverse("wagtailimages:image_usage", args=(self.id, )) search_fields = CollectionMember.search_fields + [ index.SearchField("title", partial_match=True, boost=10), index.AutocompleteField("title"), index.FilterField("title"), index.RelatedFields( "tags", [ index.SearchField("name", partial_match=True, boost=10), index.AutocompleteField("name"), ], ), index.FilterField("uploaded_by_user"), ] def __str__(self): return self.title def get_rect(self): return Rect(0, 0, self.width, self.height) def get_focal_point(self): if (self.focal_point_x is not None and self.focal_point_y is not None and self.focal_point_width is not None and self.focal_point_height is not None): return Rect.from_point( self.focal_point_x, self.focal_point_y, self.focal_point_width, self.focal_point_height, ) def has_focal_point(self): return self.get_focal_point() is not None def set_focal_point(self, rect): if rect is not None: self.focal_point_x = rect.centroid_x self.focal_point_y = rect.centroid_y self.focal_point_width = rect.width self.focal_point_height = rect.height else: self.focal_point_x = None self.focal_point_y = None self.focal_point_width = None self.focal_point_height = None def get_suggested_focal_point(self): with self.get_willow_image() as willow: faces = willow.detect_faces() if faces: # Create a bounding box around all faces left = min(face[0] for face in faces) top = min(face[1] for face in faces) right = max(face[2] for face in faces) bottom = max(face[3] for face in faces) focal_point = Rect(left, top, right, bottom) else: features = willow.detect_features() if features: # Create a bounding box around all features left = min(feature[0] for feature in features) top = min(feature[1] for feature in features) right = max(feature[0] for feature in features) bottom = max(feature[1] for feature in features) focal_point = Rect(left, top, right, bottom) else: return None # Add 20% to width and height and give it a minimum size x, y = focal_point.centroid width, height = focal_point.size width *= 1.20 height *= 1.20 width = max(width, 100) height = max(height, 100) return Rect.from_point(x, y, width, height) @classmethod def get_rendition_model(cls): """Get the Rendition model for this Image model""" return cls.renditions.rel.related_model def get_rendition(self, filter: Union["Filter", str]) -> "AbstractRendition": """ Returns a ``Rendition*`` instance with a ``file`` field value (an image) reflecting the supplied ``filter`` value and focal point values from this object. *If using custom image models, an instance of the custom rendition model will be returned. """ if isinstance(filter, str): filter = Filter(spec=filter) Rendition = self.get_rendition_model() try: rendition = self.find_existing_rendition(filter) except Rendition.DoesNotExist: rendition = self.create_rendition(filter) # Reuse this rendition if requested again from this object if "renditions" in getattr(self, "_prefetched_objects_cache", {}): self._prefetched_objects_cache[ "renditions"]._result_cache.append(rendition) try: cache = caches["renditions"] key = Rendition.construct_cache_key(self.id, filter.get_cache_key(self), filter.spec) cache.set(key, rendition) except InvalidCacheBackendError: pass return rendition def find_existing_rendition(self, filter: "Filter") -> "AbstractRendition": """ Returns an existing ``Rendition*`` instance with a ``file`` field value (an image) reflecting the supplied ``filter`` value and focal point values from this object. If no such rendition exists, a ``DoesNotExist`` error is raised for the relevant model. *If using custom image models, an instance of the custom rendition model will be returned. """ Rendition = self.get_rendition_model() cache_key = filter.get_cache_key(self) # Interrogate prefetched values first (if available) if "renditions" in getattr(self, "_prefetched_objects_cache", {}): for rendition in self.renditions.all(): if (rendition.filter_spec == filter.spec and rendition.focal_point_key == cache_key): return rendition # If renditions were prefetched, assume that if a suitable match # existed, it would have been present and already returned above # (avoiding further cache/db lookups) raise Rendition.DoesNotExist # Next, query the cache (if configured) try: cache = caches["renditions"] key = Rendition.construct_cache_key(self.id, cache_key, filter.spec) cached_rendition = cache.get(key) if cached_rendition: return cached_rendition except InvalidCacheBackendError: pass # Resort to a get() lookup return self.renditions.get(filter_spec=filter.spec, focal_point_key=cache_key) def create_rendition(self, filter: "Filter") -> "AbstractRendition": """ Creates and returns a ``Rendition*`` instance with a ``file`` field value (an image) reflecting the supplied ``filter`` value and focal point values from this object. This method is usually called by ``Image.get_rendition()``, after first checking that a suitable rendition does not already exist. *If using custom image models, an instance of the custom rendition model will be returned. """ # Because of unique constraints applied to the model, we use # get_or_create() to guard against race conditions rendition, created = self.renditions.get_or_create( filter_spec=filter.spec, focal_point_key=filter.get_cache_key(self), defaults={"file": self.generate_rendition_file(filter)}, ) return rendition def generate_rendition_file(self, filter: "Filter") -> File: """ Generates an in-memory image matching the supplied ``filter`` value and focal point value from this object, wraps it in a ``File`` object with a suitable filename, and returns it. The return value is used as the ``file`` field value for rendition objects saved by ``AbstractImage.create_rendition()``. NOTE: The responsibility of generating the new image from the original falls to the supplied ``filter`` object. If you want to do anything custom with rendition images (for example, to preserve metadata from the original image), you might want to consider swapping out ``filter`` for an instance of a custom ``Filter`` subclass of your design. """ cache_key = filter.get_cache_key(self) logger.debug( "Generating '%s' rendition for image %d", filter.spec, self.pk, ) start_time = time.time() try: generated_image = filter.run(self, BytesIO()) logger.debug( "Generated '%s' rendition for image %d in %.1fms", filter.spec, self.pk, (time.time() - start_time) * 1000, ) except: # noqa:B901,E722 logger.debug( "Failed to generate '%s' rendition for image %d", filter.spec, self.pk, ) raise # Generate filename input_filename = os.path.basename(self.file.name) input_filename_without_extension, input_extension = os.path.splitext( input_filename) output_extension = ( filter.spec.replace("|", ".") + IMAGE_FORMAT_EXTENSIONS[generated_image.format_name]) if cache_key: output_extension = cache_key + "." + output_extension # Truncate filename to prevent it going over 60 chars output_filename_without_extension = input_filename_without_extension[:( 59 - len(output_extension))] output_filename = output_filename_without_extension + "." + output_extension return File(generated_image.f, name=output_filename) def is_portrait(self): return self.width < self.height def is_landscape(self): return self.height < self.width @property def filename(self): return os.path.basename(self.file.name) @property def default_alt_text(self): # by default the alt text field (used in rich text insertion) is populated # from the title. Subclasses might provide a separate alt field, and # override this return self.title def is_editable_by_user(self, user): from wagtail.images.permissions import permission_policy return permission_policy.user_has_permission_for_instance( user, "change", self) class Meta: abstract = True
class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) file = models.ImageField( verbose_name=_('file'), upload_to=get_upload_to, width_field='width', height_field='height' ) width = models.IntegerField(verbose_name=_('width'), editable=False) height = models.IntegerField(verbose_name=_('height'), editable=False) created_at = models.DateTimeField(verbose_name=_('created at'), auto_now_add=True, db_index=True) uploaded_by_user = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('uploaded by user'), null=True, blank=True, editable=False, on_delete=models.SET_NULL ) tags = TaggableManager(help_text=None, blank=True, verbose_name=_('tags')) focal_point_x = models.PositiveIntegerField(null=True, blank=True) focal_point_y = models.PositiveIntegerField(null=True, blank=True) focal_point_width = models.PositiveIntegerField(null=True, blank=True) focal_point_height = models.PositiveIntegerField(null=True, blank=True) file_size = models.PositiveIntegerField(null=True, editable=False) # A SHA-1 hash of the file contents file_hash = models.CharField(max_length=40, blank=True, editable=False) objects = ImageQuerySet.as_manager() def _set_file_hash(self, file_contents): self.file_hash = hashlib.sha1(file_contents).hexdigest() def get_file_hash(self): if self.file_hash == '': with self.open_file() as f: self._set_file_hash(f.read()) self.save(update_fields=['file_hash']) return self.file_hash def get_upload_to(self, filename): folder_name = 'original_images' filename = self.file.field.storage.get_valid_name(filename) # do a unidecode in the filename and then # replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding filename = "".join((i if ord(i) < 128 else '_') for i in string_to_ascii(filename)) # Truncate filename so it fits in the 100 character limit # https://code.djangoproject.com/ticket/9893 full_path = os.path.join(folder_name, filename) if len(full_path) >= 95: chars_to_trim = len(full_path) - 94 prefix, extension = os.path.splitext(filename) filename = prefix[:-chars_to_trim] + extension full_path = os.path.join(folder_name, filename) return full_path def get_usage(self): return get_object_usage(self) @property def usage_url(self): return reverse('wagtailimages:image_usage', args=(self.id,)) search_fields = CollectionMember.search_fields + [ index.SearchField('title', partial_match=True, boost=10), index.AutocompleteField('title'), index.FilterField('title'), index.RelatedFields('tags', [ index.SearchField('name', partial_match=True, boost=10), index.AutocompleteField('name'), ]), index.FilterField('uploaded_by_user'), ] def __str__(self): return self.title def get_rect(self): return Rect(0, 0, self.width, self.height) def get_focal_point(self): if self.focal_point_x is not None and \ self.focal_point_y is not None and \ self.focal_point_width is not None and \ self.focal_point_height is not None: return Rect.from_point( self.focal_point_x, self.focal_point_y, self.focal_point_width, self.focal_point_height, ) def has_focal_point(self): return self.get_focal_point() is not None def set_focal_point(self, rect): if rect is not None: self.focal_point_x = rect.centroid_x self.focal_point_y = rect.centroid_y self.focal_point_width = rect.width self.focal_point_height = rect.height else: self.focal_point_x = None self.focal_point_y = None self.focal_point_width = None self.focal_point_height = None def get_suggested_focal_point(self): with self.get_willow_image() as willow: faces = willow.detect_faces() if faces: # Create a bounding box around all faces left = min(face[0] for face in faces) top = min(face[1] for face in faces) right = max(face[2] for face in faces) bottom = max(face[3] for face in faces) focal_point = Rect(left, top, right, bottom) else: features = willow.detect_features() if features: # Create a bounding box around all features left = min(feature[0] for feature in features) top = min(feature[1] for feature in features) right = max(feature[0] for feature in features) bottom = max(feature[1] for feature in features) focal_point = Rect(left, top, right, bottom) else: return None # Add 20% to width and height and give it a minimum size x, y = focal_point.centroid width, height = focal_point.size width *= 1.20 height *= 1.20 width = max(width, 100) height = max(height, 100) return Rect.from_point(x, y, width, height) @classmethod def get_rendition_model(cls): """ Get the Rendition model for this Image model """ return cls.renditions.rel.related_model def get_rendition(self, filter): if isinstance(filter, str): filter = Filter(spec=filter) cache_key = filter.get_cache_key(self) Rendition = self.get_rendition_model() try: rendition_caching = True cache = caches['renditions'] rendition_cache_key = Rendition.construct_cache_key( self.id, cache_key, filter.spec ) cached_rendition = cache.get(rendition_cache_key) if cached_rendition: return cached_rendition except InvalidCacheBackendError: rendition_caching = False try: rendition = self.renditions.get( filter_spec=filter.spec, focal_point_key=cache_key, ) except Rendition.DoesNotExist: # Generate the rendition image try: logger.debug( "Generating '%s' rendition for image %d", filter.spec, self.pk, ) start_time = time.time() generated_image = filter.run(self, BytesIO()) logger.debug( "Generated '%s' rendition for image %d in %.1fms", filter.spec, self.pk, (time.time() - start_time) * 1000 ) except: # noqa:B901,E722 logger.debug("Failed to generate '%s' rendition for image %d", filter.spec, self.pk) raise # Generate filename input_filename = os.path.basename(self.file.name) input_filename_without_extension, input_extension = os.path.splitext(input_filename) # A mapping of image formats to extensions FORMAT_EXTENSIONS = { 'jpeg': '.jpg', 'png': '.png', 'gif': '.gif', 'webp': '.webp', } output_extension = filter.spec.replace('|', '.') + FORMAT_EXTENSIONS[generated_image.format_name] if cache_key: output_extension = cache_key + '.' + output_extension # Truncate filename to prevent it going over 60 chars output_filename_without_extension = input_filename_without_extension[:(59 - len(output_extension))] output_filename = output_filename_without_extension + '.' + output_extension rendition, created = self.renditions.get_or_create( filter_spec=filter.spec, focal_point_key=cache_key, defaults={'file': File(generated_image.f, name=output_filename)} ) if rendition_caching: cache.set(rendition_cache_key, rendition) return rendition def is_portrait(self): return (self.width < self.height) def is_landscape(self): return (self.height < self.width) @property def filename(self): return os.path.basename(self.file.name) @property def default_alt_text(self): # by default the alt text field (used in rich text insertion) is populated # from the title. Subclasses might provide a separate alt field, and # override this return self.title def is_editable_by_user(self, user): from wagtail.images.permissions import permission_policy return permission_policy.user_has_permission_for_instance(user, 'change', self) class Meta: abstract = True
class ProductProxy(index.Indexed, get_model("catalogue", "Product")): def popularity(self): months_to_run = settings.OSCAR_SEARCH.get( "MONTHS_TO_RUN_ANALYTICS", 3) orders_above_date = timezone.now() - relativedelta( months=months_to_run) Line = get_model("order", "Line") return Line.objects.filter( product=self, order__date_placed__gte=orders_above_date).count() def price(self): selector = get_class("partner.strategy", "Selector") strategy = selector().strategy() if self.is_parent: return strategy.fetch_for_parent(self).price.incl_tax return strategy.fetch_for_product(self).price.incl_tax def string_attrs(self): return [str(a.value_as_text) for a in self.attribute_values.all()] def attrs(self): values = self.attribute_values.all().select_related("attribute") result = {} for value in values: at = value.attribute if at.type == at.OPTION: result[value.attribute.code] = value.value.option elif at.type == at.MULTI_OPTION: result[value.attribute.code] = [ a.option for a in value.value ] elif es_type_for_product_attribute(at) != "text": result[value.attribute.code] = value.value if self.is_parent: for child in ProductProxy.objects.filter(parent=self): result = merge_dicts(result, child.attrs()) return result def object(self): "Mimic a haystack search result" return self def category_id(self): return self.categories.values_list("id", flat=True) def category_name(self): return list(self.categories.values_list("name", flat=True)) @classmethod def get_search_fields(cls): # hook extra_product_fields for overriding return process_product_fields(super().get_search_fields()) search_fields = [ index.FilterField("id"), index.SearchField("title", partial_match=True, boost=2), index.AutocompleteField("title"), index.AutocompleteField("upc", es_extra={"analyzer": "keyword"}), index.FilterField("upc"), index.SearchField("upc", boost=3, es_extra={"analyzer": "keyword"}), index.SearchField("description", partial_match=True), index.FilterField("popularity"), index.FilterField("price", es_extra={"type": "double"}), index.FilterField("category_id"), index.SearchField("category_name", partial_match=True), index.AutocompleteField("category_name"), index.RelatedFields( "categories", [ index.SearchField("description", partial_match=True), index.SearchField("slug"), index.SearchField("full_name"), index.SearchField("get_absolute_url"), ], ), index.RelatedFields( "stockrecords", [ index.FilterField("price_currency"), index.SearchField("partner_sku"), index.SearchField("price_excl_tax"), index.FilterField("partner"), index.FilterField("num_in_stock"), ], ), index.FilterField("parent_id"), index.FilterField("structure"), index.FilterField("is_standalone"), index.FilterField("slug"), index.FilterField("rating"), index.FilterField("date_created"), index.FilterField("date_updated"), index.SearchField("string_attrs"), index.FilterField("attrs", es_extra=product_attributes_es_config()), ] class Meta: proxy = True
class ProgrammePage(BasePage): parent_page_types = ["ProgrammeIndexPage"] subpage_types = [] template = "patterns/pages/programmes/programme_detail.html" # Comments resemble tabbed panels in the editor # Content degree_level = models.ForeignKey(DegreeLevel, on_delete=models.SET_NULL, blank=False, null=True, related_name="+") programme_type = models.ForeignKey( ProgrammeType, on_delete=models.SET_NULL, blank=False, null=True, related_name="+", ) hero_image = models.ForeignKey( "images.CustomImage", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) hero_video = models.URLField(blank=True) hero_video_preview_image = models.ForeignKey( "images.CustomImage", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) hero_colour_option = models.PositiveSmallIntegerField( choices=(HERO_COLOUR_CHOICES)) # Key Details programme_details_credits = models.CharField(max_length=25, blank=True) programme_details_credits_suffix = models.CharField( max_length=1, choices=(("1", "credits"), ("2", "credits at FHEQ Level 6")), blank=True, ) programme_details_time = models.CharField(max_length=25, blank=True) programme_details_time_suffix = models.CharField( max_length=1, choices=( ("1", "year programme"), ("2", "month programme"), ("3", "week programme"), ), blank=True, ) programme_details_duration = models.CharField( max_length=1, choices=( ("1", "Full-time study"), ("2", "Full-time study with part-time option"), ("3", "Part-time study"), ), blank=True, ) next_open_day_date = models.DateField(blank=True, null=True) link_to_open_days = models.URLField(blank=True) application_deadline = models.DateField(blank=True, null=True) application_deadline_options = models.CharField( max_length=1, choices=( ("1", "Applications closed. Please check back soon."), ("2", "Still accepting applications"), ), blank=True, ) programme_specification = models.ForeignKey( "documents.CustomDocument", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) # Programme Overview programme_description_title = models.CharField(max_length=125, blank=True) programme_description_subtitle = models.CharField(max_length=500, blank=True) programme_image = models.ForeignKey( get_image_model_string(), null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) programme_video_caption = models.CharField( blank=True, max_length=80, help_text="The text dipsplayed next to the video play button", ) programme_video = models.URLField(blank=True) programme_description_copy = RichTextField(blank=True) programme_gallery = StreamField([("slide", GalleryBlock())], blank=True, verbose_name="Programme gallery") # Staff staff_link = models.URLField(blank=True) staff_link_text = models.CharField( max_length=125, blank=True, help_text="E.g. 'See all programme staff'") facilities_snippet = models.ForeignKey( "utils.FacilitiesSnippet", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) facilities_gallery = StreamField( [( "slide", StructBlock([("title", CharBlock()), ("image", ImageChooserBlock())]), )], blank=True, ) notable_alumni_links = StreamField( [( "Link_to_person", StructBlock( [("name", CharBlock()), ("link", URLBlock(required=False))], icon="link", ), )], blank=True, ) contact_email = models.EmailField(blank=True) contact_url = models.URLField(blank=True) contact_image = models.ForeignKey( "images.CustomImage", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) # TODO # Alumni Stories Carousel (api fetch) # Related Content (news and events api fetch) # Programme Curriculumm curriculum_image = models.ForeignKey( get_image_model_string(), null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) curriculum_subtitle = models.CharField(blank=True, max_length=100) curriculum_video_caption = models.CharField( blank=True, max_length=80, help_text="The text dipsplayed next to the video play button", ) curriculum_video = models.URLField(blank=True) curriculum_text = models.TextField(blank=True, max_length=250) # Pathways pathway_blocks = StreamField( [("accordion_block", AccordionBlockWithTitle())], blank=True, verbose_name="Accordion blocks", ) what_you_will_cover_blocks = StreamField( [ ("accordion_block", AccordionBlockWithTitle()), ("accordion_snippet", SnippetChooserBlock("utils.AccordionSnippet")), ], blank=True, verbose_name="Accordion blocks", ) # Requirements requirements_text = RichTextField(blank=True) requirements_blocks = StreamField( [ ("accordion_block", AccordionBlockWithTitle()), ("accordion_snippet", SnippetChooserBlock("utils.AccordionSnippet")), ], blank=True, verbose_name="Accordion blocks", ) # fees fees_disclaimer = models.ForeignKey( "utils.FeeDisclaimerSnippet", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) # Scholarships scholarships_title = models.CharField(max_length=120) scholarships_information = models.CharField(max_length=250) scholarship_accordion_items = StreamField( [("accordion", AccordionBlockWithTitle())], blank=True) scholarship_information_blocks = StreamField( [("information_block", InfoBlock())], blank=True) # More information more_information_blocks = StreamField([("information_block", InfoBlock())], blank=True) # Apply disable_apply_tab = models.BooleanField( default=0, help_text=( "This setting will remove the apply tab from this programme. " "This setting is ignored if the feature has already been disabled" " at the global level in Settings > Programme settings."), ) apply_image = models.ForeignKey( get_image_model_string(), blank=True, null=True, on_delete=models.SET_NULL, related_name="+", ) steps = StreamField( [ ("step", StepBlock()), ("step_snippet", SnippetChooserBlock("utils.StepSnippet")), ], blank=True, ) content_panels = BasePage.content_panels + [ # Taxonomy, relationships etc FieldPanel("degree_level"), InlinePanel("subjects", label="Subjects"), FieldPanel( "programme_type", help_text="Used to show content related to this programme page", ), MultiFieldPanel( [ ImageChooserPanel("hero_image"), FieldPanel("hero_video"), ImageChooserPanel("hero_video_preview_image"), FieldPanel("hero_colour_option"), ], heading="Hero", ), MultiFieldPanel( [InlinePanel("related_programmes", label="Related programmes")], heading="Related Programmes", ), MultiFieldPanel( [ InlinePanel( "related_schools_and_research_pages", label="Related Schools and Research Pages", max_num=1, ) ], heading="Related Schools and Research pages", ), ] key_details_panels = [ MultiFieldPanel( [ FieldPanel("programme_details_credits"), FieldPanel("programme_details_credits_suffix"), FieldPanel("programme_details_time"), FieldPanel("programme_details_time_suffix"), FieldPanel("programme_details_duration"), ], heading="Details", ), FieldPanel("next_open_day_date"), FieldPanel("link_to_open_days"), FieldPanel("application_deadline"), FieldPanel( "application_deadline_options", help_text="Optionally display information about the deadline", ), InlinePanel("career_opportunities", label="Career Opportunities"), DocumentChooserPanel("programme_specification"), ] programme_overview_pannels = [ MultiFieldPanel( [ FieldPanel("programme_description_title"), FieldPanel("programme_description_subtitle"), ImageChooserPanel("programme_image"), FieldPanel("programme_video_caption"), FieldPanel("programme_video"), FieldPanel("programme_description_copy"), ], heading="Programme Description", ), MultiFieldPanel([StreamFieldPanel("programme_gallery")], heading="Programme gallery"), MultiFieldPanel( [ InlinePanel("related_staff", max_num=2), FieldPanel("staff_link"), FieldPanel("staff_link_text"), ], heading="Staff", ), MultiFieldPanel( [ SnippetChooserPanel("facilities_snippet"), StreamFieldPanel("facilities_gallery"), ], heading="Facilities", ), MultiFieldPanel([StreamFieldPanel("notable_alumni_links")], heading="Alumni"), MultiFieldPanel( [ ImageChooserPanel("contact_image"), FieldPanel("contact_email"), FieldPanel("contact_url"), ], heading="Contact information", ), ] programme_curriculum_pannels = [ MultiFieldPanel( [ ImageChooserPanel("curriculum_image"), FieldPanel("curriculum_subtitle"), FieldPanel("curriculum_video"), FieldPanel("curriculum_video_caption"), FieldPanel("curriculum_text"), ], heading="Curriculum introduction", ), MultiFieldPanel([StreamFieldPanel("pathway_blocks")], heading="Pathways"), MultiFieldPanel( [StreamFieldPanel("what_you_will_cover_blocks")], heading="What you'll cover", ), ] programme_requirements_pannels = [ FieldPanel("requirements_text"), StreamFieldPanel("requirements_blocks"), ] programme_fees_and_funding_panels = [ SnippetChooserPanel("fees_disclaimer"), MultiFieldPanel([InlinePanel("fee_items", label="Fee items")], heading="For this program"), MultiFieldPanel( [ FieldPanel("scholarships_title"), FieldPanel("scholarships_information"), StreamFieldPanel("scholarship_accordion_items"), StreamFieldPanel("scholarship_information_blocks"), ], heading="Scholarships", ), MultiFieldPanel([StreamFieldPanel("more_information_blocks")], heading="More information"), ] programme_apply_pannels = [ MultiFieldPanel([FieldPanel("disable_apply_tab")], heading="Apply tab settings"), MultiFieldPanel([ImageChooserPanel("apply_image")], heading="Introduction image"), MultiFieldPanel([StreamFieldPanel("steps")], heading="Before you begin"), ] edit_handler = TabbedInterface([ ObjectList(content_panels, heading="Content"), ObjectList(key_details_panels, heading="Key details"), ObjectList(programme_overview_pannels, heading="Overview"), ObjectList(programme_curriculum_pannels, heading="Curriculum"), ObjectList(programme_requirements_pannels, heading="Requirements"), ObjectList(programme_fees_and_funding_panels, heading="Fees"), ObjectList(programme_apply_pannels, heading="Apply"), ObjectList(BasePage.promote_panels, heading="Promote"), ObjectList(BasePage.settings_panels, heading="Settings"), ]) search_fields = BasePage.search_fields + [ index.SearchField("programme_description_subtitle", partial_match=True), index.AutocompleteField("programme_description_subtitle", partial_match=True), index.SearchField("pathway_blocks", partial_match=True), index.AutocompleteField("pathway_blocks", partial_match=True), index.RelatedFields( "programme_type", [ index.SearchField("display_name", partial_match=True), index.AutocompleteField("display_name", partial_match=True), ], ), index.RelatedFields( "degree_level", [ index.SearchField("title", partial_match=True), index.AutocompleteField("title", partial_match=True), ], ), index.RelatedFields( "subjects", [ index.RelatedFields( "subject", [ index.SearchField("title", partial_match=True), index.AutocompleteField("title", partial_match=True), ], ) ], ), ] api_fields = [ APIField("degree_level", serializer=degree_level_serializer()), APIField("subjects"), APIField("programme_type"), APIField("programme_description_subtitle"), APIField("pathway_blocks"), APIField( name="hero_image_square", serializer=ImageRenditionField("fill-580x580", source="hero_image"), ), APIField("related_schools_and_research_pages"), ] def get_alumni_stories(self, programme_type_legacy_slug): # Use the slug as prefix to the cache key cache_key = f"{programme_type_legacy_slug}_programme_latest_alumni_stories" stories_data = cache.get(cache_key) if stories_data is None: try: stories_data = content.pull_alumni_stories( programme_type_legacy_slug) except content.CantPullFromRcaApi: return [] else: cache.set(cache_key, stories_data, settings.API_CONTENT_CACHE_TIMEOUT) return stories_data def get_news_and_events(self, programme_type_legacy_slug): # Use the slug as prefix to the cache key cache_key = f"{programme_type_legacy_slug}_programme_latest_news_and_events" news_and_events_data = cache.get(cache_key) if news_and_events_data is None: try: news_and_events_data = content.pull_news_and_events( programme_type_legacy_slug) except content.CantPullFromRcaApi: return [] else: cache.set(cache_key, news_and_events_data, settings.API_CONTENT_CACHE_TIMEOUT) return news_and_events_data def clean(self): errors = defaultdict(list) if self.hero_video and not self.hero_video_preview_image: errors["hero_video_preview_image"].append( "Please add a preview image for the video.") if self.programme_details_credits and not self.programme_details_credits_suffix: errors["programme_details_credits_suffix"].append( "Please add a suffix") if self.programme_details_credits_suffix and not self.programme_details_credits: errors["programme_details_credits"].append( "Please add a credit value") if self.programme_details_time and not self.programme_details_time_suffix: errors["programme_details_time_suffix"].append( "Please add a suffix") if self.programme_details_time_suffix and not self.programme_details_time: errors["programme_details_time"].append("Please add a time value") if self.curriculum_video: try: embed = embeds.get_embed(self.curriculum_video) except EmbedException: errors["curriculum_video"].append("invalid embed URL") else: if embed.provider_name.lower() != "youtube": errors["curriculum_video"].append( "Only YouTube videos are supported for this field ") if self.staff_link and not self.staff_link_text: errors["staff_link_text"].append( "Please the text to be used for the link") if self.staff_link_text and not self.staff_link: errors["staff_link_text"].append( "Please add a URL value for the link") if not self.contact_email and not self.contact_url: errors["contact_url"].append( "Please add a target value for the contact us link") if self.contact_email and self.contact_url: errors["contact_url"].append( "Only one of URL or an Email value is supported here") if not self.search_description: errors["search_description"].append( "Please add a search description for the page.") if errors: raise ValidationError(errors) def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) context["hero_colour"] = "dark" if int(self.hero_colour_option) == LIGHT_TEXT_ON_DARK_IMAGE: context["hero_colour"] = "light" context["related_sections"] = [{ "title": "Related programmes", "related_items": [ rel.page.specific for rel in self.related_programmes.select_related("page") ], }] context["related_staff"] = self.related_staff.select_related("image") # If one of the slides in the the programme_gallery contains author information # we need to set a modifier for block in self.programme_gallery: if block.value["author"]: context[ "programme_slideshow_modifier"] = "slideshow--author-info" # Set the page tab titles context["tabs"] = [ { "title": "Overview" }, { "title": "Curriculum" }, { "title": "Requirements" }, { "title": "Fees & funding" }, ] # Only add the 'apply tab' depending global settings or specific programme page settings programme_settings = ProgrammeSettings.for_site(request.site) if not programme_settings.disable_apply_tab and not self.disable_apply_tab: context["tabs"].append({"title": "Apply"}) # Global fields from ProgrammePageGlobalFieldsSettings programme_page_global_fields = ProgrammePageGlobalFieldsSettings.for_site( request.site) context["programme_page_global_fields"] = programme_page_global_fields return context
class ShortCoursePage(ContactFieldsMixin, BasePage): template = "patterns/pages/shortcourses/short_course.html" parent_page_types = ["programmes.ProgrammeIndexPage"] hero_image = models.ForeignKey( "images.CustomImage", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) introduction = models.CharField(max_length=500, blank=True) introduction_image = models.ForeignKey( get_image_model_string(), null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) video_caption = models.CharField( blank=True, max_length=80, help_text="The text dipsplayed next to the video play button", ) video = models.URLField(blank=True) body = RichTextField(blank=True) about = StreamField( [("accordion_block", AccordionBlockWithTitle())], blank=True, verbose_name=_("About the course"), ) access_planit_course_id = models.IntegerField(blank=True, null=True) frequently_asked_questions = models.ForeignKey( "utils.ShortCourseDetailSnippet", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) terms_and_conditions = models.ForeignKey( "utils.ShortCourseDetailSnippet", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) course_details_text = RichTextField(blank=True) show_register_link = models.BooleanField( default=1, help_text="If selected, an automatic 'Register your interest' link will be \ visible in the key details section", ) course_details_text = RichTextField(blank=True) programme_type = models.ForeignKey( ProgrammeType, on_delete=models.SET_NULL, blank=False, null=True, related_name="+", ) location = RichTextField(blank=True, features=["link"]) introduction = models.CharField(max_length=500, blank=True) quote_carousel = StreamField( [("quote", QuoteBlock())], blank=True, verbose_name=_("Quote carousel") ) staff_title = models.CharField( max_length=50, blank=True, help_text="Heading to display above the short course team members, E.G Programme Team", ) gallery = StreamField( [("slide", GalleryBlock())], blank=True, verbose_name="Gallery" ) external_links = StreamField( [("link", LinkBlock())], blank=True, verbose_name="External Links" ) application_form_url = models.URLField( blank=True, help_text="Adding an application form URL will override the Access Planit booking modal", ) manual_registration_url = models.URLField( blank=True, help_text="Override the register interest link show in the modal", ) access_planit_and_course_data_panels = [ MultiFieldPanel( [ FieldPanel("manual_registration_url"), HelpPanel( "Defining course details manually will override any Access Planit data configured for this page" ), InlinePanel("manual_bookings", label="Booking"), ], heading="Manual course configuration", ), MultiFieldPanel( [FieldPanel("application_form_url")], heading="Application URL" ), FieldPanel("access_planit_course_id"), MultiFieldPanel( [ FieldPanel("course_details_text"), SnippetChooserPanel("frequently_asked_questions"), SnippetChooserPanel("terms_and_conditions"), ], heading="course details", ), ] content_panels = BasePage.content_panels + [ MultiFieldPanel([ImageChooserPanel("hero_image")], heading=_("Hero"),), MultiFieldPanel( [ FieldPanel("introduction"), ImageChooserPanel("introduction_image"), FieldPanel("video"), FieldPanel("video_caption"), FieldPanel("body"), ], heading=_("Course Introduction"), ), StreamFieldPanel("about"), FieldPanel("programme_type"), StreamFieldPanel("quote_carousel"), MultiFieldPanel( [FieldPanel("staff_title"), InlinePanel("related_staff", label="Staff")], heading="Short course team", ), StreamFieldPanel("gallery"), MultiFieldPanel([*ContactFieldsMixin.panels], heading="Contact information"), MultiFieldPanel( [InlinePanel("related_programmes", label="Related programmes")], heading="Related Programmes", ), MultiFieldPanel( [ InlinePanel( "related_schools_and_research_pages", label=_("Related Schools and Research centre pages"), ) ], heading=_("Related Schools and Research Centre pages"), ), StreamFieldPanel("external_links"), ] key_details_panels = [ InlinePanel("fee_items", label="Fees"), FieldPanel("location"), FieldPanel("show_register_link"), InlinePanel("subjects", label=_("Subjects")), ] edit_handler = TabbedInterface( [ ObjectList(content_panels, heading="Content"), ObjectList(key_details_panels, heading="Key details"), ObjectList( access_planit_and_course_data_panels, heading="Course configuration" ), ObjectList(BasePage.promote_panels, heading="Promote"), ObjectList(BasePage.settings_panels, heading="Settings"), ] ) search_fields = BasePage.search_fields + [ index.SearchField("introduction", partial_match=True), index.AutocompleteField("introduction", partial_match=True), index.RelatedFields( "programme_type", [ index.SearchField("display_name", partial_match=True), index.AutocompleteField("display_name", partial_match=True), ], ), index.RelatedFields( "subjects", [ index.RelatedFields( "subject", [ index.SearchField("title", partial_match=True), index.AutocompleteField("title", partial_match=True), ], ) ], ), ] api_fields = [ # Fields for filtering and display, shared with programmes.ProgrammePage. APIField("subjects"), APIField("programme_type"), APIField("related_schools_and_research_pages"), APIField("summary", serializer=CharFieldSerializer(source="introduction")), APIField( name="hero_image_square", serializer=ImageRenditionField("fill-580x580", source="hero_image"), ), ] @property def get_manual_bookings(self): return self.manual_bookings.all() def get_access_planit_data(self): access_planit_course_data = AccessPlanitXML( course_id=self.access_planit_course_id ) return access_planit_course_data.get_data() def _format_booking_bar(self, register_interest_link=None, access_planit_data=None): """ Booking bar messaging with the next course data available Find the next course date marked as status='available' and advertise this date in the booking bar. If there are no courses available, add a default message.""" booking_bar = { "message": "Bookings not yet open", "action": "Register your interest for upcoming dates", } # If there are no dates the booking link should go to a form, not open # a modal, this link is also used as a generic interest link too though. booking_bar["link"] = register_interest_link # If manual_booking links are defined, format the booking bar and return # it before checking access planit if self.manual_bookings.first(): date = self.manual_bookings.first() booking_bar["message"] = "Next course starts" booking_bar["date"] = date.start_date if date.booking_link: booking_bar["action"] = ( f"Book from \xA3{date.cost}" if date.cost else "Book now" ) booking_bar["modal"] = "booking-details" booking_bar["cost"] = date.cost return booking_bar # If there is access planit data, format the booking bar if access_planit_data: for date in access_planit_data: if date["status"] == "Available": booking_bar["message"] = "Next course starts" booking_bar["date"] = date["start_date"] booking_bar["cost"] = date["cost"] if self.application_form_url: # URL has been provided to override AccessPlanit booking booking_bar["action"] = "Complete form to apply" booking_bar["link"] = self.application_form_url booking_bar["modal"] = None else: # Open AccessPlanit booking modal booking_bar["action"] = f"Book now from \xA3{date['cost']}" booking_bar["link"] = None booking_bar["modal"] = "booking-details" break return booking_bar # Check for a manual_registration_url if there is no data if self.manual_registration_url: booking_bar["link"] = self.manual_registration_url return booking_bar def clean(self): super().clean() errors = defaultdict(list) if ( self.show_register_link and not self.manual_registration_url and not self.access_planit_course_id ): errors["show_register_link"].append( "An access planit course ID or manual registration link is needed to show the register links" ) if self.access_planit_course_id: try: checker = AccessPlanitCourseChecker( course_id=self.access_planit_course_id ) if not checker.course_exists(): errors["access_planit_course_id"].append( "Could not find a course with this ID" ) except AccessPlanitException: errors["access_planit_course_id"].append( "Error checking this course ID in Access Planit. Please try again shortly." ) if errors: raise ValidationError(errors) def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) access_planit_data = self.get_access_planit_data() context[ "register_interest_link" ] = ( register_interest_link ) = f"{settings.ACCESS_PLANIT_REGISTER_INTEREST_BASE}?course_id={self.access_planit_course_id}" context["booking_bar"] = self._format_booking_bar( register_interest_link, access_planit_data ) context["booking_bar"] = self._format_booking_bar( register_interest_link, access_planit_data ) context["access_planit_data"] = access_planit_data context["shortcourse_details_fees"] = self.fee_items.values_list( "text", flat=True ) context["related_sections"] = [ { "title": "More opportunities to study at the RCA", "related_items": [ rel.page.specific for rel in self.related_programmes.select_related("page") ], } ] context["related_staff"] = self.related_staff.select_related( "image" ).prefetch_related("page") return context
class Memorial(I18nPage): """A geographic place on earth.""" template = "cms/preview/memorial.html" parent_page_types = ["LocationIndex"] title_image = models.ForeignKey( "ImageMedia", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", verbose_name=_(TXT["memorial_site.title_image"]), help_text=_(TXT["memorial_site.title_image.help"]), ) remembered_authors = ParentalManyToManyField( "Author", # db_table=DB_TABLE_PREFIX + "memorial_author", related_name="memorials", blank=False, verbose_name=_(TXT["memorial_site.authors"]), help_text=_(TXT["memorial_site.authors.help"]), ) memorial_type_tags = ParentalManyToManyField( "MemorialTag", db_table=DB_TABLE_PREFIX + "memorial_site_tag_memorial_type", related_name="memorial_site", blank=False, verbose_name=_(TXT["memorial_site.memorial_type_tags.plural"]), help_text=_(TXT["memorial_site.memorial_type_tags.help"]), ) address = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.address"]), help_text=_(TXT["memorial_site.address.help"]), ) address_de = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.address"]), help_text=_(TXT["memorial_site.address.help"]), ) address_cs = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.address"]), help_text=_(TXT["memorial_site.address.help"]), ) i18n_address = TranslatedField.named("address", True) contact_info = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.contact_info"]), help_text=_(TXT["memorial_site.contact_info.help"]), ) contact_info_de = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.contact_info"]), help_text=_(TXT["memorial_site.contact_info.help"]), ) contact_info_cs = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.contact_info"]), help_text=_(TXT["memorial_site.contact_info.help"]), ) i18n_contact_info = TranslatedField.named("contact_info", True) directions = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.directions"]), help_text=_(TXT["memorial_site.directions.help"]), ) directions_de = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.directions"]), help_text=_(TXT["memorial_site.directions.help"]), ) directions_cs = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.directions"]), help_text=_(TXT["memorial_site.directions.help"]), ) i18n_directions = TranslatedField.named("directions") coordinates = PointField( verbose_name=_(TXT["memorial_site.coordinates"]), help_text=_(TXT["memorial_site.coordinates.help"]), ) introduction = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.introduction"]), help_text=_(TXT["memorial_site.introduction.help"]), ) introduction_de = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.introduction"]), help_text=_(TXT["memorial_site.introduction.help"]), ) introduction_cs = RichTextField( blank=True, features=I18nPage.RICH_TEXT_FEATURES, verbose_name=_(TXT["memorial_site.introduction"]), help_text=_(TXT["memorial_site.introduction.help"]), ) i18n_introduction = TranslatedField.named("introduction") description = StreamField( [("paragraph", ParagraphStructBlock())], blank=True, verbose_name=_(TXT["memorial_site.description"]), help_text=_(TXT["memorial_site.description.help"]), ) description_de = StreamField( [("paragraph", ParagraphStructBlock())], blank=True, verbose_name=_(TXT["memorial_site.description"]), help_text=_(TXT["memorial_site.description.help"]), ) description_cs = StreamField( [("paragraph", ParagraphStructBlock())], blank=True, verbose_name=_(TXT["memorial_site.description"]), help_text=_(TXT["memorial_site.description.help"]), ) i18n_description = TranslatedField.named("description") detailed_description = StreamField( [("paragraph", ParagraphStructBlock())], blank=True, verbose_name=_(TXT["memorial_site.detailed_description"]), help_text=_(TXT["memorial_site.detailed_description.help"]), ) detailed_description_de = StreamField( [("paragraph", ParagraphStructBlock())], blank=True, verbose_name=_(TXT["memorial_site.detailed_description"]), help_text=_(TXT["memorial_site.detailed_description.help"]), ) detailed_description_cs = StreamField( [("paragraph", ParagraphStructBlock())], blank=True, verbose_name=_(TXT["memorial_site.detailed_description"]), help_text=_(TXT["memorial_site.detailed_description.help"]), ) i18n_detailed_description = TranslatedField.named("detailed_description") search_fields = I18nPage.search_fields + [ index.SearchField("address"), index.SearchField("address_de"), index.SearchField("address_cs"), index.SearchField("contact_info"), index.SearchField("contact_info_de"), index.SearchField("contact_info_cs"), index.SearchField("directions"), index.SearchField("directions_de"), index.SearchField("directions_cs"), index.SearchField("introduction"), index.SearchField("introduction_de"), index.SearchField("introduction_cs"), index.SearchField("description"), index.AutocompleteField("description_de"), index.AutocompleteField("description_cs"), index.AutocompleteField("detailed_description"), index.AutocompleteField("detailed_description_de"), index.AutocompleteField("detailed_description_cs"), index.AutocompleteField("address"), index.AutocompleteField("address_de"), index.AutocompleteField("address_cs"), index.AutocompleteField("contact_info"), index.AutocompleteField("contact_info_de"), index.AutocompleteField("contact_info_cs"), index.AutocompleteField("directions"), index.AutocompleteField("directions_de"), index.AutocompleteField("directions_cs"), index.AutocompleteField("introduction"), index.AutocompleteField("introduction_de"), index.AutocompleteField("introduction_cs"), index.AutocompleteField("description"), index.AutocompleteField("description_de"), index.AutocompleteField("description_cs"), index.AutocompleteField("detailed_description"), index.AutocompleteField("detailed_description_de"), index.AutocompleteField("detailed_description_cs"), index.FilterField("remembered_authors"), index.FilterField("memorial_type_tags"), index.FilterField("coordinates"), ] api_fields = I18nPage.api_fields + [ APIField("title_image"), APIField("remembered_authors"), APIField("memorial_type_tags"), APIField("address"), APIField("address_de"), APIField("address_cs"), APIField("contact_info"), APIField("contact_info_de"), APIField("contact_info_cs"), APIField("directions"), APIField("directions_de"), APIField("directions_cs"), APIField("introduction"), APIField("introduction_de"), APIField("introduction_cs"), APIField("description"), APIField("description_de"), APIField("description_cs"), APIField("detailed_description"), APIField("detailed_description_de"), APIField("detailed_description_cs"), APIField("coordinates"), ] general_panels = [ ImageChooserPanel("title_image"), FieldPanel( "memorial_type_tags", widget=autocomplete.ModelSelect2Multiple( url="autocomplete-location-type", ), ), FieldPanel( "remembered_authors", widget=autocomplete.ModelSelect2Multiple( url="autocomplete-author", ), ), FieldPanelTabs( children=[ FieldPanelTab("address", heading=_(TXT["language.en"])), FieldPanelTab("address_de", heading=_(TXT["language.de"])), FieldPanelTab("address_cs", heading=_(TXT["language.cs"])), ], heading=_(TXT["memorial_site.address"]), show_label=False, ), FieldPanelTabs( children=[ FieldPanelTab("contact_info", heading=_(TXT["language.en"])), FieldPanelTab("contact_info_de", heading=_(TXT["language.de"])), FieldPanelTab("contact_info_cs", heading=_(TXT["language.cs"])), ], heading=_(TXT["memorial_site.contact_info"]), show_label=False, ), FieldPanelTabs( children=[ FieldPanelTab("directions", heading=_(TXT["language.en"])), FieldPanelTab("directions_de", heading=_(TXT["language.de"])), FieldPanelTab("directions_cs", heading=_(TXT["language.cs"])), ], heading=_(TXT["memorial_site.directions"]), show_label=False, ), FieldPanel("coordinates", widget=CustomMapWidget()), ] english_panels = I18nPage.english_panels + [ FieldPanel("introduction"), StreamFieldPanel("description"), StreamFieldPanel("detailed_description"), ] german_panels = I18nPage.german_panels + [ FieldPanel("introduction_de"), StreamFieldPanel("description_de"), StreamFieldPanel("detailed_description_de"), ] czech_panels = I18nPage.czech_panels + [ FieldPanel("introduction_cs"), StreamFieldPanel("description_cs"), StreamFieldPanel("detailed_description_cs"), ] meta_panels = I18nPage.meta_panels + [FieldPanel("slug")] edit_handler = TabbedInterface([ ObjectList(general_panels, heading=_(TXT["heading.general"])), ObjectList(english_panels, heading=_(TXT["heading.en"])), ObjectList(german_panels, heading=_(TXT["heading.de"])), ObjectList(czech_panels, heading=_(TXT["heading.cs"])), ObjectList(meta_panels, heading=_(TXT["heading.meta"])), ]) class Meta: db_table = DB_TABLE_PREFIX + "memorial" verbose_name = _(TXT["memorial"]) verbose_name_plural = _(TXT["memorial.plural"]) class JSONAPIMeta: resource_name = 'memorials'
class ContentPage(BasePage): is_creatable = False show_in_menus = True # This field is used in search indexing as # we can't change the search_analyzer property # of the default title field search_title = models.CharField(max_length=255, ) legacy_guid = models.CharField(blank=True, null=True, max_length=255, help_text="""Wordpress GUID""") legacy_content = models.TextField( blank=True, null=True, help_text="""Legacy content, pre-conversion""") body = StreamField([ ("heading2", blocks.Heading2Block()), ("heading3", blocks.Heading3Block()), ("heading4", blocks.Heading4Block()), ("heading5", blocks.Heading5Block()), ( "text_section", blocks.TextBlock( blank=True, features=RICH_TEXT_FEATURES, help_text= """Some text to describe what this section is about (will be displayed above the list of child pages)""", ), ), ("image", blocks.ImageBlock()), ("embed_video", blocks.EmbedVideoBlock(help_text="""Embed a video""")), ("media", blocks.InternalMediaBlock(help_text="""Link to a media block""")), ( "data_table", blocks.DataTableBlock( help_text= """ONLY USE THIS FOR TABLULAR DATA, NOT FOR FORMATTING"""), ), ]) pinned_phrases = models.CharField( blank=True, null=True, max_length=1000, help_text="A comma separated list of pinned keywords and phrases. " "Do not use quotes for phrases. The page will be pinned " "to the first page of search results for these terms.", ) excluded_phrases = models.CharField( blank=True, null=True, max_length=1000, help_text="A comma separated list of excluded keywords and phrases. " "Do not use quotes for phrases. The page will be removed " "from search results for these terms", ) body_no_html = models.TextField( blank=True, null=True, ) @property def preview_text(self): if self.body_no_html: parts = self.body_no_html.split(" ") return " ".join(parts[0:40]) return None subpage_types = [] search_fields = Page.search_fields + [ index.SearchField( "search_title", partial_match=True, boost=2, es_extra={ "search_analyzer": "stop_and_synonyms", }, ), index.SearchField( "body_no_html", partial_match=True, es_extra={ "search_analyzer": "stop_and_synonyms", }, ), index.AutocompleteField("body_no_html"), index.AutocompleteField("search_title"), index.FilterField("slug"), ] content_panels = Page.content_panels + [ StreamFieldPanel("body"), ] promote_panels = [ FieldPanel("slug"), FieldPanel("show_in_menus"), FieldPanel("pinned_phrases"), FieldPanel("excluded_phrases"), ] def full_clean(self, *args, **kwargs): # Required so we can override # search analyzer (see above) self.search_title = self.title super().full_clean(*args, **kwargs) def save(self, *args, **kwargs): body_string = str(self.body) self.body_no_html = BeautifulSoup(body_string, "html.parser").text # Required so we can override # search analyzer (see above) # self.search_title = self.title # if self.id: manage_excluded(self, self.excluded_phrases) manage_pinned(self, self.pinned_phrases) return super().save(*args, **kwargs)