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 @@
{{ article.excerpt }}
+{{ related_article.excerpt|truncatewords:15 }}
+ {{ related_article.created_at|date:"M d, Y" }} +Have questions or need more information about the topics covered in this article? Get in touch with us!
+Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.
++ + By {{ article.author.get_full_name|default:article.author.username }} + + + {{ article.created_at|date:"M d, Y" }} + +
+{{ article.excerpt|truncatewords:20 }}
+Can't find what you're looking for? Let us know what topics you'd like to see covered!
+