From d1926cfc174f9643963b4227f94f3fad92ca1906 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 6 Jun 2025 14:53:49 +0200 Subject: [PATCH 1/6] articles --- hub/services/admin/__init__.py | 1 + hub/services/admin/articles.py | 90 ++++++++ hub/services/migrations/0034_article.py | 118 ++++++++++ hub/services/models/__init__.py | 1 + hub/services/models/articles.py | 94 ++++++++ hub/services/templates/base.html | 2 + .../templates/services/article_detail.html | 160 +++++++++++++ .../templates/services/article_list.html | 217 ++++++++++++++++++ hub/services/templatetags/social_meta_tags.py | 4 - hub/services/urls.py | 2 + hub/services/views/__init__.py | 1 + hub/services/views/articles.py | 176 ++++++++++++++ 12 files changed, 862 insertions(+), 4 deletions(-) create mode 100644 hub/services/admin/articles.py create mode 100644 hub/services/migrations/0034_article.py create mode 100644 hub/services/models/articles.py create mode 100644 hub/services/templates/services/article_detail.html create mode 100644 hub/services/templates/services/article_list.html create mode 100644 hub/services/views/articles.py diff --git a/hub/services/admin/__init__.py b/hub/services/admin/__init__.py index 75c0d33..3c79092 100644 --- a/hub/services/admin/__init__.py +++ b/hub/services/admin/__init__.py @@ -1,6 +1,7 @@ # Admin module initialization # Import all admin classes to register them with Django admin +from .articles import * from .base import * from .content import * from .leads import * diff --git a/hub/services/admin/articles.py b/hub/services/admin/articles.py new file mode 100644 index 0000000..e6dad8c --- /dev/null +++ b/hub/services/admin/articles.py @@ -0,0 +1,90 @@ +""" +Admin configuration for Article model +""" + +from django.contrib import admin +from django.utils.html import format_html +from django import forms +from django.core.exceptions import ValidationError + + +from ..models import Article + + +class ArticleAdminForm(forms.ModelForm): + """Custom form for Article admin with validation""" + + class Meta: + model = Article + fields = "__all__" + + def clean_title(self): + """Validate title length""" + title = self.cleaned_data.get("title") + if title and len(title) > 50: + raise ValidationError("Title must be 50 characters or less.") + return title + + def clean_excerpt(self): + """Validate excerpt length""" + excerpt = self.cleaned_data.get("excerpt") + if excerpt and len(excerpt) > 200: + raise ValidationError("Excerpt must be 200 characters or less.") + return excerpt + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + """Admin configuration for Article model""" + + form = ArticleAdminForm + + list_display = ( + "title", + "author", + "image_preview", + "is_published", + "is_featured", + "created_at", + ) + list_filter = ( + "is_published", + "is_featured", + "author", + "related_service", + "related_consulting_partner", + "related_cloud_provider", + "created_at", + ) + search_fields = ("title", "excerpt", "content", "meta_keywords") + prepopulated_fields = {"slug": ("title",)} + readonly_fields = ("created_at", "updated_at") + + def image_preview(self, obj): + """Display image preview in admin list view""" + if obj.image: + return format_html( + '', obj.image.url + ) + return "No image" + + image_preview.short_description = "Image" + + def related_to_display(self, obj): + """Display what this article is related to""" + return obj.related_to + + related_to_display.short_description = "Related To" + + def get_queryset(self, request): + """Optimize queries by selecting related objects""" + return ( + super() + .get_queryset(request) + .select_related( + "author", + "related_service", + "related_consulting_partner", + "related_cloud_provider", + ) + ) diff --git a/hub/services/migrations/0034_article.py b/hub/services/migrations/0034_article.py new file mode 100644 index 0000000..e485625 --- /dev/null +++ b/hub/services/migrations/0034_article.py @@ -0,0 +1,118 @@ +# Generated by Django 5.2 on 2025-06-06 07:47 + +import django.db.models.deletion +import hub.services.models.base +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0033_vshnappcatprice_public_display_enabled_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Article", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200)), + ("slug", models.SlugField(max_length=250, unique=True)), + ( + "excerpt", + models.TextField( + help_text="Brief description of the article", max_length=500 + ), + ), + ("content", models.TextField()), + ( + "meta_keywords", + models.CharField( + blank=True, + help_text="SEO keywords separated by commas", + max_length=255, + ), + ), + ( + "image", + models.ImageField( + help_text="Title picture for the article", + upload_to="article_images/", + validators=[hub.services.models.base.validate_image_size], + ), + ), + ( + "is_published", + models.BooleanField( + default=False, + help_text="Only published articles are visible to users", + ), + ), + ( + "is_featured", + models.BooleanField( + default=False, + help_text="Featured articles appear prominently in listings", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="articles", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "related_cloud_provider", + models.ForeignKey( + blank=True, + help_text="Link this article to a cloud provider", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="articles", + to="services.cloudprovider", + ), + ), + ( + "related_consulting_partner", + models.ForeignKey( + blank=True, + help_text="Link this article to a consulting partner", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="articles", + to="services.consultingpartner", + ), + ), + ( + "related_service", + models.ForeignKey( + blank=True, + help_text="Link this article to a specific service", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="articles", + to="services.service", + ), + ), + ], + options={ + "verbose_name": "Article", + "verbose_name_plural": "Articles", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/hub/services/models/__init__.py b/hub/services/models/__init__.py index f5c3107..b29a71e 100644 --- a/hub/services/models/__init__.py +++ b/hub/services/models/__init__.py @@ -1,3 +1,4 @@ +from .articles import * from .base import * from .content import * from .leads import * diff --git a/hub/services/models/articles.py b/hub/services/models/articles.py new file mode 100644 index 0000000..781c54c --- /dev/null +++ b/hub/services/models/articles.py @@ -0,0 +1,94 @@ +from django.db import models +from django.urls import reverse +from django.utils.text import slugify +from django.contrib.auth.models import User +from django_prose_editor.fields import ProseEditorField +from .base import validate_image_size +from .services import Service +from .providers import CloudProvider, ConsultingPartner + + +class Article(models.Model): + title = models.CharField(max_length=200) + slug = models.SlugField(max_length=250, unique=True) + excerpt = models.TextField( + max_length=500, help_text="Brief description of the article" + ) + content = ProseEditorField() + meta_keywords = models.CharField( + max_length=255, blank=True, help_text="SEO keywords separated by commas" + ) + image = models.ImageField( + upload_to="article_images/", + help_text="Title picture for the article", + ) + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles") + + # Relations to other models + related_service = models.ForeignKey( + Service, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="articles", + help_text="Link this article to a specific service", + ) + related_consulting_partner = models.ForeignKey( + ConsultingPartner, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="articles", + help_text="Link this article to a consulting partner", + ) + related_cloud_provider = models.ForeignKey( + CloudProvider, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="articles", + help_text="Link this article to a cloud provider", + ) + + # Publishing controls + is_published = models.BooleanField( + default=False, help_text="Only published articles are visible to users" + ) + is_featured = models.BooleanField( + default=False, help_text="Featured articles appear prominently in listings" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + verbose_name = "Article" + verbose_name_plural = "Articles" + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + # Auto-generate slug from title if not provided + if not self.slug: + self.slug = slugify(self.title) + counter = 1 + while Article.objects.filter(slug=self.slug).exists(): + self.slug = f"{slugify(self.title)}-{counter}" + counter += 1 + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("services:article_detail", kwargs={"slug": self.slug}) + + @property + def related_to(self): + """Returns a string describing what this article is related to""" + if self.related_service: + return f"Service: {self.related_service.name}" + elif self.related_consulting_partner: + return f"Partner: {self.related_consulting_partner.name}" + elif self.related_cloud_provider: + return f"Provider: {self.related_cloud_provider.name}" + return "General" diff --git a/hub/services/templates/base.html b/hub/services/templates/base.html index b549e08..1f2261f 100644 --- a/hub/services/templates/base.html +++ b/hub/services/templates/base.html @@ -55,6 +55,7 @@ diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html new file mode 100644 index 0000000..b1159ad --- /dev/null +++ b/hub/services/templates/services/article_detail.html @@ -0,0 +1,160 @@ +{% extends 'base.html' %} +{% load static %} +{% load contact_tags %} + +{% block title %}{{ article.title }}{% endblock %} +{% block meta_description %}{{ article.excerpt }}{% endblock %} +{% block meta_keywords %}{{ article.meta_keywords }}{% endblock %} + +{% block content %} +
+
+
+

{{ article.title }}

+
+

{{ article.excerpt }}

+
+ By {{ article.author.get_full_name|default:article.author.username }} + + {{ article.created_at|date:"M d, Y" }} + {% if article.updated_at != article.created_at %} + {% endif %} +
+
+
+
+
+ +{% if article.image %} +
+
+
+ {{ article.title }} +
+
+
+{% endif %} + +
+
+
+
+
+
+ {{ article.content|safe }} +
+
+ + + {% if article.related_service or article.related_consulting_partner or article.related_cloud_provider %} +
+

Related Links

+
+ {% if article.related_service %} +
+
+
+
Service
+

{{ article.related_service.name }}

+ View Service +
+
+
+ {% endif %} + {% if article.related_consulting_partner %} +
+
+
+
Partner
+

{{ article.related_consulting_partner.name }}

+ View Partner +
+
+
+ {% endif %} + {% if article.related_cloud_provider %} +
+
+
+
Provider
+

{{ article.related_cloud_provider.name }}

+ View Provider +
+
+
+ {% endif %} +
+
+ {% endif %} + + + {% if related_articles %} +
+

Related Articles

+
+ {% for related_article in related_articles %} +
+
+ {% if related_article.image %} + {{ related_article.title }} + {% endif %} +
+
{{ related_article.title }}
+

{{ related_article.excerpt|truncatewords:15 }}

+ {{ related_article.created_at|date:"M d, Y" }} +
+
+
+ {% endfor %} +
+
+ {% endif %} + + +
+
+ + + + + + Back to Articles + +
+ + +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+

Questions about this article?

+
+

Have questions or need more information about the topics covered in this article? Get in touch with us!

+
+
+ {% embedded_contact_form source="Article Inquiry" %} +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/hub/services/templates/services/article_list.html b/hub/services/templates/services/article_list.html new file mode 100644 index 0000000..edb0989 --- /dev/null +++ b/hub/services/templates/services/article_list.html @@ -0,0 +1,217 @@ +{% extends 'base.html' %} +{% load static %} +{% load contact_tags %} + +{% block title %}Articles{% endblock %} +{% block meta_description %}Explore all articles on Servala, covering cloud services, consulting partners, and cloud provider insights.{% endblock %} + +{% block content %} +
+
+
+

Articles

+
+

Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.

+
+
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + +
+ +
+
+ +
+ + + +
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+ Clear +
+
+
+
+
+ + +
+
+ {% for article in articles %} +
+ +
+ {% empty %} +
+
+ No articles found matching your criteria. +
+
+ {% endfor %} +
+
+
+
+
+ +
+
+
+
+ +
+
+
+

Looking for specific content?

+
+

Can't find what you're looking for? Let us know what topics you'd like to see covered!

+
+
+ {% embedded_contact_form source="Article Request" %} +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/hub/services/templatetags/social_meta_tags.py b/hub/services/templatetags/social_meta_tags.py index d8eb107..cc2591d 100644 --- a/hub/services/templatetags/social_meta_tags.py +++ b/hub/services/templatetags/social_meta_tags.py @@ -67,10 +67,6 @@ def social_meta_tags(context): - - - - """ return mark_safe(tags) diff --git a/hub/services/urls.py b/hub/services/urls.py index 552ead5..2ead4b0 100644 --- a/hub/services/urls.py +++ b/hub/services/urls.py @@ -18,6 +18,8 @@ urlpatterns = [ ), path("provider//", views.provider_detail, name="provider_detail"), path("partner//", views.partner_detail, name="partner_detail"), + path("articles/", views.article_list, name="article_list"), + path("article//", views.article_detail, name="article_detail"), path("contact/", views.leads.contact, name="contact"), path("contact/thank-you/", views.thank_you, name="thank_you"), path("contact-form/", views.contact_form, name="contact_form"), diff --git a/hub/services/views/__init__.py b/hub/services/views/__init__.py index 0af9c30..56d2142 100644 --- a/hub/services/views/__init__.py +++ b/hub/services/views/__init__.py @@ -1,3 +1,4 @@ +from .articles import * from .leads import * from .offerings import * from .partners import * diff --git a/hub/services/views/articles.py b/hub/services/views/articles.py new file mode 100644 index 0000000..6589d6f --- /dev/null +++ b/hub/services/views/articles.py @@ -0,0 +1,176 @@ +from django.shortcuts import render, get_object_or_404 +from django.db.models import Q +from hub.services.models import ( + Article, + Service, + ConsultingPartner, + CloudProvider, +) + + +def article_list(request): + """View for listing articles with filtering capabilities""" + # Get basic filter parameters + search_query = request.GET.get("search", "") + service_id = request.GET.get("service", "") + consulting_partner_id = request.GET.get("consulting_partner", "") + cloud_provider_id = request.GET.get("cloud_provider", "") + + # Start with all published articles + all_articles = Article.objects.filter(is_published=True) + articles = all_articles + + # Apply filters based on request parameters + if search_query: + articles = articles.filter( + Q(title__icontains=search_query) + | Q(excerpt__icontains=search_query) + | Q(content__icontains=search_query) + | Q(meta_keywords__icontains=search_query) + ) + + if service_id: + articles = articles.filter(related_service__id=service_id) + + if consulting_partner_id: + articles = articles.filter(related_consulting_partner__id=consulting_partner_id) + + if cloud_provider_id: + articles = articles.filter(related_cloud_provider__id=cloud_provider_id) + + # Order articles: featured first, then by creation date (newest first) + articles = articles.order_by( + "-is_featured", # Featured first (True before False) + "-created_at", # Newest first + ) + + # Create base querysets for each filter type that apply all OTHER current filters + # This way, each filter shows options that would return results if selected + + # For service filter options, apply all other filters except service + service_filter_base = all_articles + if search_query: + service_filter_base = service_filter_base.filter( + Q(title__icontains=search_query) + | Q(excerpt__icontains=search_query) + | Q(content__icontains=search_query) + | Q(meta_keywords__icontains=search_query) + ) + if consulting_partner_id: + service_filter_base = service_filter_base.filter( + related_consulting_partner__id=consulting_partner_id + ) + if cloud_provider_id: + service_filter_base = service_filter_base.filter( + related_cloud_provider__id=cloud_provider_id + ) + + # For consulting partner filter options, apply all other filters except consulting_partner + cp_filter_base = all_articles + if search_query: + cp_filter_base = cp_filter_base.filter( + Q(title__icontains=search_query) + | Q(excerpt__icontains=search_query) + | Q(content__icontains=search_query) + | Q(meta_keywords__icontains=search_query) + ) + if service_id: + cp_filter_base = cp_filter_base.filter(related_service__id=service_id) + if cloud_provider_id: + cp_filter_base = cp_filter_base.filter( + related_cloud_provider__id=cloud_provider_id + ) + + # For cloud provider filter options, apply all other filters except cloud_provider + cloud_filter_base = all_articles + if search_query: + cloud_filter_base = cloud_filter_base.filter( + Q(title__icontains=search_query) + | Q(excerpt__icontains=search_query) + | Q(content__icontains=search_query) + | Q(meta_keywords__icontains=search_query) + ) + if service_id: + cloud_filter_base = cloud_filter_base.filter(related_service__id=service_id) + if consulting_partner_id: + cloud_filter_base = cloud_filter_base.filter( + related_consulting_partner__id=consulting_partner_id + ) + + # Get available services, consulting partners and cloud providers that would return results if selected + available_services = Service.objects.filter( + disable_listing=False, + id__in=service_filter_base.values_list( + "related_service__id", flat=True + ).distinct(), + ).distinct() + + available_consulting_partners = ConsultingPartner.objects.filter( + disable_listing=False, + id__in=cp_filter_base.values_list( + "related_consulting_partner__id", flat=True + ).distinct(), + ).distinct() + + available_cloud_providers = CloudProvider.objects.filter( + disable_listing=False, + id__in=cloud_filter_base.values_list( + "related_cloud_provider__id", flat=True + ).distinct(), + ).distinct() + + context = { + "articles": articles, + "available_services": available_services, + "available_consulting_partners": available_consulting_partners, + "available_cloud_providers": available_cloud_providers, + "search_query": search_query, + } + + return render(request, "services/article_list.html", context) + + +def article_detail(request, slug): + """View for displaying article details""" + article = get_object_or_404( + Article.objects.select_related( + "author", + "related_service", + "related_consulting_partner", + "related_cloud_provider" + ).filter(is_published=True), + slug=slug, + ) + + # Get related articles (same service, partner, or provider) + related_articles = Article.objects.filter( + is_published=True + ).exclude(id=article.id) + + if article.related_service: + related_articles = related_articles.filter( + related_service=article.related_service + ) + elif article.related_consulting_partner: + related_articles = related_articles.filter( + related_consulting_partner=article.related_consulting_partner + ) + elif article.related_cloud_provider: + related_articles = related_articles.filter( + related_cloud_provider=article.related_cloud_provider + ) + else: + # If no specific relation, get other general articles + related_articles = related_articles.filter( + related_service__isnull=True, + related_consulting_partner__isnull=True, + related_cloud_provider__isnull=True + ) + + related_articles = related_articles.order_by("-created_at")[:3] + + context = { + "article": article, + "related_articles": related_articles, + } + return render(request, "services/article_detail.html", context) \ No newline at end of file From abee64a0de046beec7bec92c8bf70a5967307b40 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 6 Jun 2025 14:59:23 +0200 Subject: [PATCH 2/6] improved open graph for articles --- hub/services/templatetags/social_meta_tags.py | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/hub/services/templatetags/social_meta_tags.py b/hub/services/templatetags/social_meta_tags.py index cc2591d..03fe0cb 100644 --- a/hub/services/templatetags/social_meta_tags.py +++ b/hub/services/templatetags/social_meta_tags.py @@ -59,14 +59,68 @@ def social_meta_tags(context): else description ) + elif view_name == "services:article_detail" and "article" in context: + article = context["article"] + title = f"Servala - {article.title}" + description = article.excerpt + # Use article image if available, otherwise default + if article.image: + image_url = request.build_absolute_uri(article.image.url) + + # Determine og:type based on view + og_type = "website" # default + if view_name == "services:article_detail" and "article" in context: + og_type = "article" + # Generate the HTML for meta tags tags = f""" - + """ + # Add article-specific meta tags if this is an article detail page + if view_name == "services:article_detail" and "article" in context: + article = context["article"] + + # Add article-specific Open Graph tags + article_tags = f""" + + + + """ + + # Add article section if related to service, partner, or provider + if article.related_service: + article_tags += ( + f'\n ' + ) + elif article.related_consulting_partner: + article_tags += ( + f'\n ' + ) + elif article.related_cloud_provider: + article_tags += ( + f'\n ' + ) + else: + article_tags += f'\n ' + + # Add meta keywords as article tags if available + if article.meta_keywords: + keywords = [ + keyword.strip() + for keyword in article.meta_keywords.split(",") + if keyword.strip() + ] + for keyword in keywords: + article_tags += ( + f'\n ' + ) + + tags += article_tags + return mark_safe(tags) From edb1aa1de20fea9084397b9caf67d20cf36149f3 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 6 Jun 2025 15:00:12 +0200 Subject: [PATCH 3/6] hide articles from nav until ready --- hub/services/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/services/templates/base.html b/hub/services/templates/base.html index 1f2261f..93d9364 100644 --- a/hub/services/templates/base.html +++ b/hub/services/templates/base.html @@ -55,7 +55,7 @@ {% endif %} + + + {% if service_articles %} +
+

Articles about {{ offering.service.name }}

+ +
+ {% endif %} + + + {% if provider_articles %} +
+

Articles about {{ offering.cloud_provider.name }}

+ +
+ {% endif %} @@ -402,4 +446,6 @@ + + {% endblock %} \ No newline at end of file diff --git a/hub/services/templates/services/partner_detail.html b/hub/services/templates/services/partner_detail.html index 866302f..7e90b82 100644 --- a/hub/services/templates/services/partner_detail.html +++ b/hub/services/templates/services/partner_detail.html @@ -119,6 +119,28 @@ {% endif %} + + + {% if related_articles %} +
+

Related Articles

+ +
+ {% endif %} diff --git a/hub/services/templates/services/provider_detail.html b/hub/services/templates/services/provider_detail.html index ade6e12..151c986 100644 --- a/hub/services/templates/services/provider_detail.html +++ b/hub/services/templates/services/provider_detail.html @@ -119,6 +119,28 @@ {% endif %} + + + {% if related_articles %} +
+

Related Articles

+ +
+ {% endif %} diff --git a/hub/services/templates/services/service_detail.html b/hub/services/templates/services/service_detail.html index ca73351..5054d61 100644 --- a/hub/services/templates/services/service_detail.html +++ b/hub/services/templates/services/service_detail.html @@ -93,6 +93,28 @@ {% endif %} + + + {% if related_articles %} +
+

Related Articles

+ +
+ {% endif %} diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 4730b4a..2c876a6 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -129,10 +129,20 @@ def offering_detail(request, provider_slug, service_slug): except VSHNAppCatPrice.DoesNotExist: pass + # Get related articles for both cloud provider and service + provider_articles = offering.cloud_provider.articles.filter( + is_published=True + ).order_by("-created_at")[:3] + service_articles = offering.service.articles.filter(is_published=True).order_by( + "-created_at" + )[:3] + context = { "offering": offering, "pricing_data_by_group_and_service_level": pricing_data_by_group_and_service_level, "price_calculator_enabled": price_calculator_enabled, + "provider_articles": provider_articles, + "service_articles": service_articles, } return render(request, "services/offering_detail.html", context) diff --git a/hub/services/views/partners.py b/hub/services/views/partners.py index 4645c69..ac3668e 100644 --- a/hub/services/views/partners.py +++ b/hub/services/views/partners.py @@ -84,8 +84,14 @@ def partner_detail(request, slug): slug=slug, ) + # Get related articles for this partner + related_articles = partner.articles.filter(is_published=True).order_by( + "-created_at" + )[:3] + context = { "partner": partner, "services": partner.services.all(), + "related_articles": related_articles, } return render(request, "services/partner_detail.html", context) diff --git a/hub/services/views/providers.py b/hub/services/views/providers.py index 3e8b965..376d8df 100644 --- a/hub/services/views/providers.py +++ b/hub/services/views/providers.py @@ -64,9 +64,15 @@ def provider_detail(request, slug): ) ) + # Get related articles for this cloud provider + related_articles = provider.articles.filter(is_published=True).order_by( + "-created_at" + )[:3] + context = { "provider": provider, "services": services, "ordered_offerings": ordered_offerings, + "related_articles": related_articles, } return render(request, "services/provider_detail.html", context) diff --git a/hub/services/views/services.py b/hub/services/views/services.py index fc6d275..b9dd1da 100644 --- a/hub/services/views/services.py +++ b/hub/services/views/services.py @@ -153,7 +153,13 @@ def service_detail(request, slug): slug=slug, ) + # Get related articles for this service + related_articles = service.articles.filter(is_published=True).order_by( + "-created_at" + )[:3] + context = { "service": service, + "related_articles": related_articles, } return render(request, "services/service_detail.html", context) From 2e8e31d136aa29957a728cb2176adc4566dfa43e Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 6 Jun 2025 15:23:34 +0200 Subject: [PATCH 6/6] add top menu links to admin --- hub/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hub/settings.py b/hub/settings.py index 7ffec5a..68452ad 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -244,6 +244,8 @@ JAZZMIN_SETTINGS = { "url": "https://servala.com", "new_window": True, }, + {"name": "Articles", "url": "/admin/services/article/"}, + {"name": "FAQs", "url": "/admin/services/websitefaq/"}, ], "show_sidebar": True, "navigation_expanded": True,