diff --git a/.pylintrc b/.pylintrc index 0111a6b..ff28161 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,2 +1,3 @@ [MASTER] -ignore=migrations \ No newline at end of file +ignore=migrations +disable=C0114, # missing-module-docstring \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 5d23c7f..8e5f771 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "editor.formatOnSave": false, "python.linting.enabled": true, "python.linting.lintOnSave": true, - // "editor.fontFamily": "Dank Mono, JetBrains Mono NL, Fira Code, Menlo, Monaco, 'Courier New', monospace", - // "editor.fontSize": 14, +// "editor.fontFamily": "Dank Mono, JetBrains Mono NL, Fira Code, Menlo, Monaco, 'Courier New', monospace", +// "editor.fontSize": 14, + "python.linting.pylintArgs": ["--disable=C0111"] } \ No newline at end of file diff --git a/bloggy/.DS_Store b/bloggy/.DS_Store index 8e1f43b..9ca3d85 100644 Binary files a/bloggy/.DS_Store and b/bloggy/.DS_Store differ diff --git a/bloggy/admin/__init__.py b/bloggy/admin/__init__.py index 118d098..1d787a5 100644 --- a/bloggy/admin/__init__.py +++ b/bloggy/admin/__init__.py @@ -7,3 +7,4 @@ from .user_admin import * from .page_admin import * from .quiz_admin import * +from .newsletter_admin import * diff --git a/bloggy/admin/course_admin.py b/bloggy/admin/course_admin.py index 138c5b0..3bbd8c9 100644 --- a/bloggy/admin/course_admin.py +++ b/bloggy/admin/course_admin.py @@ -1,6 +1,9 @@ +from datetime import timezone + from django.contrib import admin from bloggy.admin import BloggyAdmin, BloggyAdminForm, seo_fieldsets, publication_fieldsets from bloggy.models.course import Course +from bloggy.services.post_service import cleanse_html class CourseForm(BloggyAdminForm): @@ -45,5 +48,6 @@ class CourseAdmin(BloggyAdmin): list_display_links = ['title'] form = CourseForm - - + def save_model(self, request, obj, form, change): + obj.description = cleanse_html(obj.description) + super().save_model(request, obj, form, change) diff --git a/bloggy/admin/misc_admin.py b/bloggy/admin/misc_admin.py index 88f58d7..efed2b7 100644 --- a/bloggy/admin/misc_admin.py +++ b/bloggy/admin/misc_admin.py @@ -2,19 +2,9 @@ import bloggy.models.option from bloggy import settings -from bloggy.models import RedirectRule admin.site.site_header = settings.SITE_TITLE.upper() admin.site.site_title = settings.SITE_TITLE admin.site.index_title = "Dashboard" admin.site.register(bloggy.models.option.Option) - -@admin.register(RedirectRule) -class RedirectRuleAdmin(admin.ModelAdmin): - list_display = ( - 'source', - 'destination', - 'status_code', - 'note', - ) diff --git a/bloggy/admin/newsletter_admin.py b/bloggy/admin/newsletter_admin.py new file mode 100644 index 0000000..b35f773 --- /dev/null +++ b/bloggy/admin/newsletter_admin.py @@ -0,0 +1,36 @@ +from django import forms +from django.contrib import admin + +from bloggy.admin import BloggyAdmin, publication_fieldsets +from bloggy.models.newsletter import Newsletter + + +class NewsletterForm(forms.ModelForm): + model = Newsletter + + +@admin.register(Newsletter) +class PageAdmin(BloggyAdmin): + prepopulated_fields = {"url": ("title",)} + list_display = ( + 'id', + 'title', + 'url', + 'publish_status', + ) + + fieldsets = ( + (None, { + 'fields': ('title', 'url', 'content',) + }), publication_fieldsets) + + search_fields = ['title'] + summernote_fields = ('content',) + readonly_fields = ['updated_date', 'created_date'] + date_hierarchy = 'published_date' + form = NewsletterForm + ordering = ('-created_date',) + list_display_links = ['title'] + + def get_form(self, request, obj=None, change=False, **kwargs): + return super().get_form(request, obj, change, **kwargs) diff --git a/bloggy/admin/page_admin.py b/bloggy/admin/page_admin.py index 63ad13d..0fbec7d 100644 --- a/bloggy/admin/page_admin.py +++ b/bloggy/admin/page_admin.py @@ -14,6 +14,7 @@ class PageAdmin(BloggyAdmin): list_display = ( 'id', 'title', + 'template_type', 'url', 'excerpt', 'publish_status', @@ -21,7 +22,7 @@ class PageAdmin(BloggyAdmin): fieldsets = ( (None, { - 'fields': ('title', 'excerpt', 'url', 'content',) + 'fields': ('title', 'template_type', 'excerpt', 'url', 'content',) }), publication_fieldsets, seo_fieldsets) search_fields = ['title'] diff --git a/bloggy/admin/post_admin.py b/bloggy/admin/post_admin.py index ac04af1..98f36aa 100644 --- a/bloggy/admin/post_admin.py +++ b/bloggy/admin/post_admin.py @@ -7,6 +7,7 @@ from bloggy.admin import BloggyAdmin, BloggyAdminForm, publication_fieldsets, seo_fieldsets from bloggy.models import Post +from bloggy.services.post_service import cleanse_html class PostForm(BloggyAdminForm): @@ -93,14 +94,14 @@ def author_link(self, obj): author_link.short_description = 'Author' def view_on_site(self, obj): - url = reverse('post_single', kwargs={'slug': obj.slug}) - return url + "?context=preview" + return obj.get_absolute_url() + "?context=preview" def save_model(self, request, obj, form, change): if "publish_status" in form.changed_data and obj.publish_status == "LIVE" and not obj.published_date: obj.published_date = timezone.now() if not obj.pk: obj.author = request.user + obj.content = cleanse_html(obj.content) super().save_model(request, obj, form, change) diff --git a/bloggy/admin/quiz_admin.py b/bloggy/admin/quiz_admin.py index 17def79..c414a79 100644 --- a/bloggy/admin/quiz_admin.py +++ b/bloggy/admin/quiz_admin.py @@ -35,7 +35,7 @@ class QuizAdmin(BloggyAdmin): }), publication_fieldsets, seo_fieldsets) - summernote_fields = ('description',) + summernote_fields = ('content',) readonly_fields = ['thumbnail_tag'] ordering = ('-display_order',) list_display_links = ['title'] diff --git a/bloggy/forms/change_password_form.py b/bloggy/forms/change_password_form.py new file mode 100644 index 0000000..d4482b2 --- /dev/null +++ b/bloggy/forms/change_password_form.py @@ -0,0 +1,30 @@ +from django import forms +from django.contrib.auth.forms import PasswordChangeForm + + +class ChangePasswordForm(PasswordChangeForm): + old_password = forms.CharField( + label="Old password", + strip=False, + widget=forms.PasswordInput( + attrs={"autocomplete": "current-password", "autofocus": True, 'class': 'form-control'} + ), + ) + + new_password1 = forms.CharField( + label="New Password", + help_text='Please use 8 or more characters with a mix of letters, numbers & symbols', + widget=forms.PasswordInput(attrs={'class': 'form-control'}, ) + ) + new_password2 = forms.CharField( + label="Confirm New Password", + widget=forms.PasswordInput(attrs={'class': 'form-control'}), + ) + + def clean_new_password2(self): + new_password1 = self.cleaned_data.get('new_password1') + new_password2 = self.cleaned_data.get('new_password2') + + if new_password1 and new_password2 and new_password1 != new_password2: + raise forms.ValidationError("Passwords do not match.") + return new_password2 diff --git a/bloggy/forms/create_newsletter_form.py b/bloggy/forms/create_newsletter_form.py new file mode 100644 index 0000000..7447efc --- /dev/null +++ b/bloggy/forms/create_newsletter_form.py @@ -0,0 +1,43 @@ +from django import forms + + +class CreateNewsletterForm(forms.Form): + title = forms.CharField( + label="Newsletter title", + max_length=254, + widget=forms.TextInput( + attrs={'class': 'form-control', 'placeholder': 'Weekly newsletter'}), + ) + + # published_date = forms.DateTimeField( + # label="Published date", + # help_text="Date time for sending the newsletter", + # widget=forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'date'}) + # ) + + json_file = forms.FileField( + label='Upload JSON File', + help_text="Upload a valid json file", + widget=forms.ClearableFileInput(attrs={'class': 'form-control'}) + ) + + send_to_all = forms.BooleanField( + label="Send to all", + required=False, + help_text="This newsletter will be send all users", + widget=forms.CheckboxInput( + attrs={ + 'class': 'form-check-input mx-2', + }), + ) + + send_to_users_only = forms.BooleanField( + label="Send to registered users only", + required=False, + help_text="This newsletter will be send only to registered users", + widget=forms.CheckboxInput( + attrs={ + 'class': 'form-check-input mx-2', + }), + + ) diff --git a/bloggy/forms/custom_input_fields.py b/bloggy/forms/custom_input_fields.py new file mode 100644 index 0000000..44ccb9a --- /dev/null +++ b/bloggy/forms/custom_input_fields.py @@ -0,0 +1,3 @@ +from django.forms import ClearableFileInput +class NonClearableFileInput(ClearableFileInput): + template_name = 'forms/widgets/non_clearable_imagefield.html' diff --git a/bloggy/forms/edit_profile_form.py b/bloggy/forms/edit_profile_form.py index 68c789b..65c26e6 100644 --- a/bloggy/forms/edit_profile_form.py +++ b/bloggy/forms/edit_profile_form.py @@ -1,25 +1,26 @@ from django import forms -from django.forms import ClearableFileInput +from bloggy.forms.custom_input_fields import NonClearableFileInput from bloggy.models import User -class NonClearableFileInput(ClearableFileInput): - template_name = 'forms/widgets/non_clearable_imagefield.html' - - class EditProfileForm(forms.ModelForm): class Meta: model = User + help_texts = { + "bio": "This will be displayed publicly on your profile. Keep it short and crisp.", + 'receive_news_updates': "News about product and feature updates", + 'receive_new_content': "Get notified when new content is added" + } + + labels = { + 'receive_news_updates': "News and updates", + 'receive_new_content': "New tutorials & courses" + } + fields = [ - 'profile_photo', - 'name', - 'website', - 'twitter', - 'linkedin', - 'youtube', - 'github', - 'bio' + 'profile_photo', 'name', 'website', 'twitter', 'linkedin', 'youtube', 'github', 'bio', + 'receive_news_updates', 'receive_new_content' ] widgets = { @@ -61,4 +62,13 @@ class Meta: 'rows': 5, 'placeholder': 'Your github' }), + + 'receive_news_updates': forms.CheckboxInput(attrs={ + 'class': 'form-check-input', + }), + + 'receive_new_content': forms.CheckboxInput(attrs={ + 'class': 'form-check-input', + }), + } diff --git a/bloggy/forms/manage_newsletter_form.py b/bloggy/forms/manage_newsletter_form.py new file mode 100644 index 0000000..d2d492e --- /dev/null +++ b/bloggy/forms/manage_newsletter_form.py @@ -0,0 +1,60 @@ +from django import forms + +from bloggy.models.newsletter import Newsletter + + +class ManageNewsLetterForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.instance = kwargs.get('instance') + + class Meta: + model = Newsletter + fields = [ + 'title', 'url', + 'content', + 'publish_status', + 'send_to_users_only', + 'send_to_all' + ] + + widgets = { + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'rows': 5, + 'placeholder': 'Enter title' + }), + + 'url': forms.TextInput(attrs={ + 'class': 'form-control', + 'rows': 5, + 'placeholder': 'Enter url' + }), + + 'content': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 15, + 'placeholder': "url", + }), + + 'publish_status': forms.Select(attrs={'class': 'form-control'}), + 'receive_news_updates': forms.CheckboxInput(attrs={ + 'class': 'form-check-input', + }), + + 'receive_new_content': forms.CheckboxInput(attrs={ + 'class': 'form-check-input', + }), + + 'send_to_all': forms.CheckboxInput( + attrs={ + 'class': 'form-check-input mx-2', + }), + + 'send_to_users_only': forms.CheckboxInput( + attrs={ + 'class': 'form-check-input mx-2', + }), + + } diff --git a/bloggy/forms/password_reset_form.py b/bloggy/forms/password_reset_form.py new file mode 100644 index 0000000..be949e0 --- /dev/null +++ b/bloggy/forms/password_reset_form.py @@ -0,0 +1,16 @@ +from django import forms +from django.contrib.auth.forms import PasswordResetForm + + +class CustomPasswordResetForm(PasswordResetForm): + def __init__(self, *args, **kwargs): + super(CustomPasswordResetForm, self).__init__(*args, **kwargs) + + email = forms.EmailField( + label="Email", + max_length=254, + widget=forms.EmailInput( + attrs={"autocomplete": "email", 'class': 'form-control', 'placeholder': 'Enter your registered email'}), + ) + + diff --git a/bloggy/forms/set_password_form.py b/bloggy/forms/set_password_form.py new file mode 100644 index 0000000..41c2c62 --- /dev/null +++ b/bloggy/forms/set_password_form.py @@ -0,0 +1,20 @@ +from django import forms +from django.contrib.auth import password_validation +from django.contrib.auth.forms import SetPasswordForm + + +class CustomSetPasswordForm(SetPasswordForm): + def __init__(self, *args, **kwargs): + super(CustomSetPasswordForm, self).__init__(*args, **kwargs) + + new_password1 = forms.CharField( + label="New password", + widget=forms.PasswordInput(attrs={"autocomplete": "new-password", 'class': 'form-control'}), + strip=False, + help_text=password_validation.password_validators_help_text_html(), + ) + new_password2 = forms.CharField( + label="Confirm password", + strip=False, + widget=forms.PasswordInput(attrs={"autocomplete": "new-password", 'class': 'form-control'}), + ) diff --git a/bloggy/forms/update_username_form.py b/bloggy/forms/update_username_form.py new file mode 100644 index 0000000..8bf2914 --- /dev/null +++ b/bloggy/forms/update_username_form.py @@ -0,0 +1,31 @@ +from django import forms +from django.contrib.auth import get_user_model + + +class UpdateUsernameForm(forms.ModelForm): + class Meta: + model = get_user_model() + fields = ['username'] + help_texts={ + 'username': 'Letters, digits and "_" only allowed.' + } + labels = { + "username": "New username", + } + + widgets = { + 'username': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Username', + }), + } + + def clean_username(self): + new_username = self.cleaned_data['username'] + user_model = get_user_model() + + # Check if the new username is available + if user_model.objects.filter(username=new_username).exclude(pk=self.instance.pk).exists(): + raise forms.ValidationError('This username is already in use. Please choose a different one.') + + return new_username diff --git a/bloggy/management/commands/newsletter.json b/bloggy/management/commands/newsletter.json new file mode 100644 index 0000000..52b7f64 --- /dev/null +++ b/bloggy/management/commands/newsletter.json @@ -0,0 +1,53 @@ +{ + "updates": { + "heading": "In the news this week..", + "list": [ + { + "title": "Project Lombok-Is it Still Relevant in 2023?", + "thumbnail": "https://media.stacktips.com/media/uploads/posts/Project_Lombok.jpg", + "link": "https://stacktips.com/articles/project-lombok-is-it-still-relevant-in-2023", + "excerpt": "What is Project Lombok? Have you used this magical library? With the new Java Records feature, you might wonder if Lombok is still relevant. Let's weigh the pros and cons and see if this is suitable for you." + }, + { + "title": "Notes to Crack CLF-C01 AWS Certified Cloud Practitioner Exam on First Attempt", + "thumbnail": "https://media.stacktips.com/media/uploads/articles/crack-clfc01-aws-certified-cloud-practitioner-in-first-attempt.jpeg", + "link": "https://stacktips.com/articles/crack-clfc01-aws-certified-cloud-practitioner-on-first-attempt", + "excerpt": "A quick guide to CLF-C01 AWS Certified Cloud Practitioner Practice exam notes covers the introduction to all the services provided by AWS." + }, + { + "title": "Replace Embedded Tomcat with Jetty or Undertow Server in Spring Boot 3", + "thumbnail": "https://i3.ytimg.com/vi/1gEoiMVULt4/maxresdefault.jpg", + "link": "https://youtu.be/1gEoiMVULt4", + "excerpt": "This video explains how to replace the default embedded Tomcat with Jetty or Undertow servers" + }, + { + "title": "Schedule Task in Spring Boot Using @Scheduled Annotation", + "thumbnail": "", + "link": "https://stacktips.com/articles/schedule-task-in-spring-boot-using-scheduled-annotation", + "excerpt": "Scheduling task in Spring boot using @Scheduled annotation with examples showcasing fixed rate, fixed delay, and using cron expressions." + } + ] + }, + + "news": { + "heading": "In the news this week..", + "list": [ + { + "title": "Elon Musk's 'Anti-Woke' AI Is Here, Snowflakes Need Not Apply", + "link": "#" + }, + { + "title": "Channel 4 and Snapchat extend and enhance partnership with 'Snap-first' programming", + "link": "#" + }, + { + "title": "TikTok adds comment filtering tools to better handle Israel-Hamas war content", + "link": "#" + }, + { + "title": "GTA trailer dropped early after leak and breaks all records.", + "link": "#" + } + ] + } +} \ No newline at end of file diff --git a/bloggy/management/commands/runseed.py b/bloggy/management/commands/runseed.py index b8658bc..848a651 100644 --- a/bloggy/management/commands/runseed.py +++ b/bloggy/management/commands/runseed.py @@ -19,7 +19,6 @@ def handle(self, *args, **options): ('seed_categories', 'categories.csv'), ('seed_posts', 'posts.csv'), ('seed_pages', 'pages.csv'), - ('seed_redirectrules', 'redirect_rules.csv'), ('update_category_count', None), ] diff --git a/bloggy/management/commands/seed_redirectrules.py b/bloggy/management/commands/seed_redirectrules.py deleted file mode 100644 index a37e6a7..0000000 --- a/bloggy/management/commands/seed_redirectrules.py +++ /dev/null @@ -1,34 +0,0 @@ -import csv - -from django.core.management.base import BaseCommand -from django.utils.text import slugify - -from bloggy.models import Category, RedirectRule - - -class Command(BaseCommand): - help = 'Importing redirect rules' - - def add_arguments(self, parser): - parser.add_argument('-f', '--file', type=str, - help="File path to import, e.g. ~/bloggy/demo_content/redirect_rules.csv") - - def handle(self, *args, **options): - file_path = options['file'] - - counter = 0 - with open(file_path, encoding="utf-8") as f: - reader = csv.reader(f) - print('Importing redirect rules', file_path) - - for index, row in enumerate(reader): - if index > 0: - counter = counter + 1 - RedirectRule.objects.get_or_create( - from_url=row[0], - to_url=row[1], - status_code=row[2], - note=row[3] - ) - - self.stdout.write(self.style.SUCCESS(f"%s redirect rules imported" % counter)) diff --git a/bloggy/management/commands/send_account_activation_reminders.py b/bloggy/management/commands/send_account_activation_reminders.py new file mode 100644 index 0000000..c68dce7 --- /dev/null +++ b/bloggy/management/commands/send_account_activation_reminders.py @@ -0,0 +1,47 @@ +from django.core.management.base import BaseCommand +from django.urls import reverse + +from bloggy import settings +from bloggy.services import email_service +from bloggy.services.token_service import create_token +from bloggy.models import User + + +class Command(BaseCommand): + help = 'Send email reminders to users who have not activated their accounts' + + def handle(self, *args, **kwargs): + # Get a list of users who have not activated their accounts + inactive_users = User.objects.filter(is_active=False) + + email_count = 0 + for user in inactive_users: + subject = 'Reminder: Verify your email to activate your account' + + verification_token = create_token(user=user, token_type="signup") + verification_link = reverse("activate_account", args=[ + verification_token.uuid, + verification_token.token + ]) + + args = { + "email_subject": subject, + "app_name": settings.SITE_TITLE, + "verification_link": settings.SITE_URL + verification_link + } + + try: + email_service.send_html_email( + subject, + [user.email], + "email/account_activation_reminder_email.html", + args) + print('Success: Account activation reminder mail sent to {}', user.email) + + except Exception as ex: + print('Error: sending email to {}: {}', user.email, ex) + + finally: + email_count = email_count + 1 + + self.stdout.write(self.style.SUCCESS(f"Reminder sent to {email_count} users")) diff --git a/bloggy/management/commands/send_card.py b/bloggy/management/commands/send_card.py new file mode 100644 index 0000000..7c60d51 --- /dev/null +++ b/bloggy/management/commands/send_card.py @@ -0,0 +1,51 @@ +from django.core.management.base import BaseCommand +from bloggy import settings +from bloggy.models.subscriber import Subscribers +from bloggy.services import email_service +from bloggy.models import User +from itertools import chain + + +# python3 manage.py send_card --cardImgUrl="https://media.stacktips.com/media/emails/christmas-card.gif" --subject="Merry Christmas." --targetLink="https://stacktips.com" +class Command(BaseCommand): + help = 'Send wish card to users' + + def add_arguments(self, parser): + parser.add_argument('--cardImgUrl', type=str, required=True, help='URL of the card') + parser.add_argument('--subject', type=str, required=True, help='Email subject') + parser.add_argument('--targetLink', type=str, required=True, help='Target link') + + def handle(self, *args, **options): + card_img_url = options['cardImgUrl'] + subject = options['subject'] + target_link = options['targetLink'] + + if card_img_url is None or target_link is None or subject is None: + self.stdout.write( + self.style.ERROR(f"Missing mandatory arguments --cardImgUrl or --subject or --targetLink")) + + else: + users = chain( + User.objects.all() + # Subscribers.objects.all(), + ) + + email_count = 0 + for user in users: + args = { + "user_name": user.name, + "email_subject": subject, + "app_name": settings.SITE_TITLE, + "card_img_url": card_img_url, + "card_target_link": target_link + } + + try: + email_service.send_html_email(subject, [user.email], "email/wish_card_email.html", args) + print('Success: Card sent to {}', user.email) + except Exception as ex: + print('Error sending card to {}: {}', user.email, ex) + finally: + email_count = email_count + 1 + + self.stdout.write(self.style.SUCCESS(f"Reminder sent to {email_count} users")) diff --git a/bloggy/management/commands/send_newsletter.py b/bloggy/management/commands/send_newsletter.py new file mode 100644 index 0000000..0e14144 --- /dev/null +++ b/bloggy/management/commands/send_newsletter.py @@ -0,0 +1,47 @@ +from django.core.management.base import BaseCommand +from bloggy import settings +from bloggy.models.subscriber import Subscribers +from bloggy.services import email_service +from bloggy.models import User +from itertools import chain + + +class Command(BaseCommand): + help = 'Send wish card to users' + + def add_arguments(self, parser): + parser.add_argument('--content', type=str, required=True, help='URL of the card') + parser.add_argument('--subject', type=str, required=True, help='Email subject') + + def handle(self, *args, **options): + content = options['content'] + subject = options['subject'] + + if content is None or subject is None: + self.stdout.write( + self.style.ERROR(f"Missing mandatory arguments --content or --subject")) + + else: + users = chain( + User.objects.all(), + Subscribers.objects.all(), + ) + + email_count = 0 + for user in users: + args = { + "user_name": user.name, + "email_subject": subject, + "app_name": settings.SITE_TITLE, + "updates": "" + } + + try: + email_service.send_html_email(subject, [user.email], "email/wish_card_email.html", args) + print('Success: Card sent to {}', user.email) + except Exception as ex: + print('Error sending card to {}: {}', user.email, ex) + finally: + email_count = email_count + 1 + + self.stdout.write(self.style.SUCCESS(f"Reminder sent to {email_count} users")) diff --git a/bloggy/middleware/redirect.py b/bloggy/middleware/redirect.py deleted file mode 100644 index 19d6044..0000000 --- a/bloggy/middleware/redirect.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging - -from django.http import HttpResponsePermanentRedirect -from django.utils.deprecation import MiddlewareMixin - -from bloggy import settings -from bloggy.models import RedirectRule - -logger = logging.getLogger(__name__) - - -class RedirectMiddleware(MiddlewareMixin): - - def process_response(self, request, response): - request_path = request.path - - # Don't do anything for /api endpoints - if request_path.startswith("/api/"): - return response - - if response.status_code == 404: - logger.warning("ERROR 404:: %s", request_path) - redirect_rule = RedirectRule.objects.filter(source__exact=request_path).first() - - if redirect_rule: - logger.warning("Explicit redirect rule found %s ==> %s", redirect_rule.source, - redirect_rule.destination) - return HttpResponsePermanentRedirect(settings.SITE_URL + redirect_rule.destination) - - return response diff --git a/bloggy/migrations/0005_page_template_type_alter_post_post_type_and_more.py b/bloggy/migrations/0005_page_template_type_alter_post_post_type_and_more.py new file mode 100644 index 0000000..e5b500d --- /dev/null +++ b/bloggy/migrations/0005_page_template_type_alter_post_post_type_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.7 on 2023-12-22 15:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0004_remove_redirectrule_is_regx'), + ] + + operations = [ + migrations.AddField( + model_name='page', + name='template_type', + field=models.CharField(blank=True, choices=[('naked', 'Naked'), ('default', 'Default'), ('wide', 'Wide')], default='default', help_text='Template type', max_length=20, null=True, verbose_name='Template type'), + ), + migrations.AlterField( + model_name='post', + name='post_type', + field=models.CharField(blank=True, choices=[['post', 'Post']], default='article', help_text='Post type', max_length=20, null=True, verbose_name='Post type'), + ), + migrations.AlterField( + model_name='vote', + name='post_type', + field=models.CharField(choices=[['post', 'Post']], help_text='Select content type', max_length=20, verbose_name='Content type'), + ), + ] diff --git a/bloggy/migrations/0006_alter_page_template_type.py b/bloggy/migrations/0006_alter_page_template_type.py new file mode 100644 index 0000000..dcfe03f --- /dev/null +++ b/bloggy/migrations/0006_alter_page_template_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2023-12-22 16:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0005_page_template_type_alter_post_post_type_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='page', + name='template_type', + field=models.CharField(blank=True, choices=[('newsletter', 'Newsletter'), ('naked', 'Naked'), ('default', 'Default')], default='default', help_text='Template type', max_length=20, null=True, verbose_name='Template type'), + ), + ] diff --git a/bloggy/migrations/0007_alter_post_difficulty_alter_post_post_type_and_more.py b/bloggy/migrations/0007_alter_post_difficulty_alter_post_post_type_and_more.py new file mode 100644 index 0000000..e4bb12a --- /dev/null +++ b/bloggy/migrations/0007_alter_post_difficulty_alter_post_post_type_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.7 on 2024-01-04 00:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0006_alter_page_template_type'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='difficulty', + field=models.CharField(blank=True, choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advance', 'Advance')], default='easy', help_text='Select difficulty', max_length=20, null=True, verbose_name='Difficulty level'), + ), + migrations.AlterField( + model_name='post', + name='post_type', + field=models.CharField(blank=True, choices=[['post', 'Post'], ['lesson', 'Lesson']], default='article', help_text='Post type', max_length=20, null=True, verbose_name='Post type'), + ), + migrations.AlterField( + model_name='vote', + name='post_type', + field=models.CharField(choices=[['post', 'Post'], ['lesson', 'Lesson']], help_text='Select content type', max_length=20, verbose_name='Content type'), + ), + ] diff --git a/bloggy/migrations/0008_user_receive_new_content_user_receive_news_updates.py b/bloggy/migrations/0008_user_receive_new_content_user_receive_news_updates.py new file mode 100644 index 0000000..60ce011 --- /dev/null +++ b/bloggy/migrations/0008_user_receive_new_content_user_receive_news_updates.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-10 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0007_alter_post_difficulty_alter_post_post_type_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='receive_new_content', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='user', + name='receive_news_updates', + field=models.BooleanField(default=True), + ), + ] diff --git a/bloggy/migrations/0009_newsletter_delete_redirectrule.py b/bloggy/migrations/0009_newsletter_delete_redirectrule.py new file mode 100644 index 0000000..9bf6082 --- /dev/null +++ b/bloggy/migrations/0009_newsletter_delete_redirectrule.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.7 on 2024-01-12 22:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0008_user_receive_new_content_user_receive_news_updates'), + ] + + operations = [ + migrations.CreateModel( + name='Newsletter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_created=True, auto_now_add=True, null=True)), + ('updated_date', models.DateTimeField(auto_now=True, null=True)), + ('title', models.CharField(help_text='Enter title', max_length=300)), + ('url', models.CharField(help_text='Enter url', max_length=150, unique=True)), + ('content', models.JSONField()), + ('content_html', models.TextField(help_text='Newsletter content', null=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('publish_status', models.CharField(blank=True, choices=[('DRAFT', 'DRAFT'), ('LIVE', 'LIVE'), ('DELETED', 'DELETED')], default='DRAFT', help_text='Select publish status', max_length=20, null=True, verbose_name='Publish status')), + ], + options={ + 'verbose_name': 'Newsletter', + 'verbose_name_plural': 'Newsletters', + }, + ), + migrations.DeleteModel( + name='RedirectRule', + ), + ] diff --git a/bloggy/migrations/0010_newsletter_send_to_all_newsletter_send_to_users_only.py b/bloggy/migrations/0010_newsletter_send_to_all_newsletter_send_to_users_only.py new file mode 100644 index 0000000..0bd0154 --- /dev/null +++ b/bloggy/migrations/0010_newsletter_send_to_all_newsletter_send_to_users_only.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-12 23:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloggy', '0009_newsletter_delete_redirectrule'), + ] + + operations = [ + migrations.AddField( + model_name='newsletter', + name='send_to_all', + field=models.BooleanField(default=False, help_text='Send to all'), + ), + migrations.AddField( + model_name='newsletter', + name='send_to_users_only', + field=models.BooleanField(default=False, help_text='Send to registered users only'), + ), + ] diff --git a/bloggy/models/__init__.py b/bloggy/models/__init__.py index 53a3f97..a6165dd 100644 --- a/bloggy/models/__init__.py +++ b/bloggy/models/__init__.py @@ -7,6 +7,5 @@ from .post import Post from .course import Course from .quizzes import Quiz, QuizQuestion, QuizAnswer, UserQuizScore -from .redirect_rule import RedirectRule from .verification_token import VerificationToken diff --git a/bloggy/models/categories.py b/bloggy/models/categories.py index 5ea3204..907c5de 100644 --- a/bloggy/models/categories.py +++ b/bloggy/models/categories.py @@ -43,6 +43,9 @@ def save(self, *args, **kwargs): def get_absolute_url(self): return reverse('categories_single', args=[str(self.slug)]) + def get_admin_url(self): + return reverse(f'admin:{self._meta.app_label}_{self._meta.model_name}_change', args=[self.id]) + def thumbnail_tag(self): if self.thumbnail_tag: return format_html(f'') diff --git a/bloggy/models/course.py b/bloggy/models/course.py index 5550186..06d6bc8 100644 --- a/bloggy/models/course.py +++ b/bloggy/models/course.py @@ -46,6 +46,9 @@ class Meta: def get_absolute_url(self): return reverse("courses_single", kwargs={"slug": str(self.slug)}) + def get_admin_url(self): + return reverse(f'admin:{self._meta.app_label}_{self._meta.model_name}_change', args=[self.id]) + @property def get_lessons(self): return self.post_set.filter(publish_status="LIVE").order_by("display_order").all() diff --git a/bloggy/models/newsletter.py b/bloggy/models/newsletter.py new file mode 100644 index 0000000..3704e80 --- /dev/null +++ b/bloggy/models/newsletter.py @@ -0,0 +1,39 @@ +from django.db import models +from django.db.models import TextField + +from bloggy.models.mixin.updatable import Updatable + + +class Newsletter(Updatable): + title = models.CharField(max_length=300, help_text='Enter title') + url = models.CharField(max_length=150, help_text='Enter url', unique=True) + content = models.JSONField() + content_html = TextField(null=True, help_text='Newsletter content') + published_date = models.DateTimeField(null=True, blank=True) + publish_status = models.CharField( + max_length=20, + choices=[ + ('DRAFT', 'DRAFT'), + ('LIVE', 'LIVE'), + ('DELETED', 'DELETED') + ], + default='DRAFT', blank=True, null=True, + help_text="Select publish status", + verbose_name="Publish status") + + send_to_users_only = models.BooleanField( + default=False, + help_text="Send to registered users only" + ) + + send_to_all = models.BooleanField( + default=False, + help_text="Send to all users" + ) + + def __str__(self): + return str(self.title) + + class Meta: + verbose_name = 'Newsletter' + verbose_name_plural = 'Newsletters' diff --git a/bloggy/models/page.py b/bloggy/models/page.py index 87143cb..6a5e664 100644 --- a/bloggy/models/page.py +++ b/bloggy/models/page.py @@ -1,5 +1,6 @@ from django.db import models from django.db.models import TextField +from django.urls import reverse from bloggy.models.mixin.SeoAware import SeoAware from bloggy.models.mixin.updatable import Updatable @@ -15,6 +16,16 @@ class Page(Updatable, SeoAware): """ title = models.CharField(max_length=300, help_text='Enter title') + template_type = models.CharField( + max_length=20, choices=[ + ('newsletter', 'Newsletter'), + ('naked', 'Naked'), + ('default', 'Default'), + ], + default='default', blank=True, null=True, + help_text="Template type", + verbose_name="Template type") + excerpt = models.CharField( max_length=500, help_text='Enter excerpt', @@ -36,6 +47,14 @@ class Page(Updatable, SeoAware): help_text="Select publish status", verbose_name="Publish status") + def get_admin_url(self): + return reverse( + f'admin:{self._meta.app_label}_{self._meta.model_name}_change' + , args=[self.id]) + + def get_admin_url(self): + return reverse(f'admin:{self._meta.app_label}_{self._meta.model_name}_change', args=[self.id]) + def __str__(self): return str(self.title) diff --git a/bloggy/models/post.py b/bloggy/models/post.py index 235fdd6..dbb0834 100644 --- a/bloggy/models/post.py +++ b/bloggy/models/post.py @@ -23,7 +23,7 @@ class Post(Content): max_length=20, choices=[ ('beginner', 'Beginner'), ('intermediate', 'Intermediate'), - ('advance', 'advance'), + ('advance', 'Advance'), ], default='easy', blank=True, null=True, help_text="Select difficulty", diff --git a/bloggy/models/quizzes.py b/bloggy/models/quizzes.py index 30da1c8..bc0ee7d 100644 --- a/bloggy/models/quizzes.py +++ b/bloggy/models/quizzes.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import TextField +from django.urls import reverse from hitcount.models import HitCount from bloggy import settings @@ -58,6 +59,9 @@ class Quiz(Content): def get_questions_json(self): return get_questions_json(self) + def get_admin_url(self): + return reverse(f'admin:{self._meta.app_label}_{self._meta.model_name}_change', args=[self.id]) + def get_questions(self): return self.quizquestion_set.all() diff --git a/bloggy/models/redirect_rule.py b/bloggy/models/redirect_rule.py deleted file mode 100644 index 102f060..0000000 --- a/bloggy/models/redirect_rule.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.db import models - -from bloggy.models.mixin.updatable import Updatable - - -class RedirectRule(Updatable): - source = models.CharField(max_length=300, help_text='Enter from url') - destination = models.CharField(max_length=300, help_text='Enter to url') - status_code = models.IntegerField( - default='standard', blank=True, null=True, - choices=[ - (301, '301 Moved Permanently'), - (307, '307 Temporary Redirect'), - ], - help_text="Redirect type", - verbose_name="Redirect type") - - note = models.CharField( - max_length=500, - help_text='Enter note', - null=True, - blank=True - ) - - def __str__(self): - return f"{self.status_code}::{self.source}" - - -class Meta: - verbose_name = "Redirect" - verbose_name_plural = "Redirects" diff --git a/bloggy/models/user.py b/bloggy/models/user.py index 77133f4..2d3357d 100644 --- a/bloggy/models/user.py +++ b/bloggy/models/user.py @@ -66,6 +66,8 @@ class User(AbstractBaseUser, ResizeImageMixin, PermissionsMixin): youtube = models.CharField(max_length=100, null=True, blank=True) github = models.CharField(max_length=100, null=True, blank=True) bio = models.TextField(max_length=250, null=True, blank=True) + receive_news_updates = models.BooleanField(default=True) + receive_new_content = models.BooleanField(default=True) class Meta: db_table = "bloggy_user" @@ -81,9 +83,11 @@ def get_bookmarks_count(self): def get_full_name_or_username(self): if self.name: return self.name - return self.username + def __str__(self): + return self.get_full_name_or_username() + def get_full_name(self): full_name = f"{self.name}" return full_name.strip() diff --git a/bloggy/services/email_service.py b/bloggy/services/email_service.py index a0a556f..2531979 100644 --- a/bloggy/services/email_service.py +++ b/bloggy/services/email_service.py @@ -1,55 +1,36 @@ from django.core.mail import send_mail from django.template.loader import render_to_string -from django.urls import reverse from bloggy import settings -def send_custom_email(subject, recipients, template, args, from_email=settings.DEFAULT_FROM_EMAIL): +def send_html_email(subject, to_email, template, args, from_email=settings.DEFAULT_FROM_EMAIL): email_body = render_to_string(template, args) send_mail( subject, email_body, from_email, - recipients, + to_email, fail_silently=False, html_message=email_body ) -def send_newsletter_verification_token(request, email, uuid, token): - subject = f'Confirm to {settings.SITE_TITLE} newsletter' - - args = { - "email_subject": subject, - "app_name": settings.SITE_TITLE, - "verification_link": request.build_absolute_uri(reverse("newsletter_verification", args=[uuid, token])) - } - - send_custom_email(subject, [email], "email/newsletter_verification_token.html", args) - - -def email_verification_token(request, new_user, token): - subject = f"{settings.SITE_TITLE} confirmation code: {token.code}" - args = { - "email_subject": subject, - "verification_code": token.code, - "app_name": settings.SITE_TITLE, - "verification_link": request.build_absolute_uri(reverse("otp_verification", args=[token.uuid])) - } - send_custom_email(subject, [new_user.email], "email/login_code_email.html", args) - - -def email_registration_token(request, new_user, verification_token): - subject = f'Welcome to {settings.SITE_TITLE}!' - args = { - "email_subject": subject, - "user_name": new_user.name, - "app_name": settings.SITE_TITLE, - "verification_link": request.build_absolute_uri(reverse("activate_account", args=[ - verification_token.uuid, - verification_token.token - ])) - } +def send_plain_email(subject, to_email, message_content, from_email=settings.DEFAULT_FROM_EMAIL): + send_mail( + subject, + message_content, + from_email, + to_email, + fail_silently=False, + ) - send_custom_email(subject, [new_user.email], "email/acc_active_email.html", args) +# def email_verification_token(request, new_user, token): +# subject = f"{settings.SITE_TITLE} confirmation code: {token.code}" +# args = { +# "email_subject": subject, +# "verification_code": token.code, +# "app_name": settings.SITE_TITLE, +# "verification_link": request.build_absolute_uri(reverse("otp_verification", args=[token.uuid])) +# } +# send_html_email(subject, [new_user.email], "email/login_code_email.html", args) diff --git a/bloggy/services/post_service.py b/bloggy/services/post_service.py index e3ec89c..876dfdd 100644 --- a/bloggy/services/post_service.py +++ b/bloggy/services/post_service.py @@ -2,6 +2,7 @@ from bloggy.models import Post, Quiz from bloggy.utils.string_utils import StringUtils +from bs4 import BeautifulSoup DEFAULT_PAGE_SIZE = 20 @@ -64,3 +65,17 @@ def set_seo_settings(post, context): if post.thumbnail: context['meta_image'] = post.thumbnail.url + + +def cleanse_html(html_string): + soup = BeautifulSoup(html_string, 'html.parser') + for tag in soup.find_all(True, {'style': True}): + del tag['style'] + + # Find and remove empty

, , and tags + for tag in soup.find_all(['p', 'a', 'span']): + if not tag and (not tag.contents or (len(tag.contents) == 1 and not tag.contents[0].strip())): + tag.extract() + + # Get the modified HTML string + return str(soup) diff --git a/bloggy/services/token_service.py b/bloggy/services/token_service.py index 347f98a..ba08e71 100644 --- a/bloggy/services/token_service.py +++ b/bloggy/services/token_service.py @@ -11,7 +11,9 @@ def create_token(user, token_type): """ - Generate token:: Token and uuid will be generated automatically + Generate token:: Token and uuid will be generated automatically, + + if expired, it will automatically renew new token. """ token = VerificationToken.objects.filter(user=user, token_type=token_type).first() if token: diff --git a/bloggy/settings.py b/bloggy/settings.py index 900ec43..a0f37c0 100644 --- a/bloggy/settings.py +++ b/bloggy/settings.py @@ -57,9 +57,8 @@ 'mail_templated', # Used for templated email https://github.com/artemrizhov/django-mail-templated 'storages', 'debug_toolbar', # dev only - 'hitcount', - 'colorfield' + 'colorfield', ] MIDDLEWARE = [ @@ -83,7 +82,6 @@ # Social login # 'social_django.middleware.SocialAuthExceptionMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware', - 'bloggy.middleware.redirect.RedirectMiddleware', # new articles mismatch url redirect ] ROOT_URLCONF = 'bloggy.urls' @@ -364,4 +362,4 @@ def get_post_types(): "propagate": False, }, }, -} \ No newline at end of file +} diff --git a/bloggy/templates/.DS_Store b/bloggy/templates/.DS_Store new file mode 100644 index 0000000..441bf42 Binary files /dev/null and b/bloggy/templates/.DS_Store differ diff --git a/bloggy/templates/auth/login.html b/bloggy/templates/auth/login.html index 2ae8608..8d08526 100644 --- a/bloggy/templates/auth/login.html +++ b/bloggy/templates/auth/login.html @@ -23,7 +23,7 @@

Sign in

{{ form.password.errors }}

- Forgot password? + Forgot password?
diff --git a/bloggy/templates/auth/password_reset.html b/bloggy/templates/auth/password_reset.html new file mode 100644 index 0000000..ff3a484 --- /dev/null +++ b/bloggy/templates/auth/password_reset.html @@ -0,0 +1,30 @@ +{% extends 'base-with-header.html' %} +{% load widget_tweaks %} +{% load static %} +{% block content %} +
+ +
+{% endblock %} diff --git a/bloggy/templates/auth/password_reset_complete.html b/bloggy/templates/auth/password_reset_complete.html new file mode 100644 index 0000000..d68bc45 --- /dev/null +++ b/bloggy/templates/auth/password_reset_complete.html @@ -0,0 +1,13 @@ +{% extends 'base-with-header.html' %} +{% load widget_tweaks %} +{% load static %} +{% block content %} +
+ +
+{% endblock %} diff --git a/bloggy/templates/auth/password_reset_confirm.html b/bloggy/templates/auth/password_reset_confirm.html new file mode 100644 index 0000000..a4a8afb --- /dev/null +++ b/bloggy/templates/auth/password_reset_confirm.html @@ -0,0 +1,27 @@ +{% extends 'base-with-header.html' %} +{% load widget_tweaks %} +{% load static %} +{% block content %} +
+ +
+{% endblock %} diff --git a/bloggy/templates/auth/password_reset_done.html b/bloggy/templates/auth/password_reset_done.html new file mode 100644 index 0000000..7f5fcd1 --- /dev/null +++ b/bloggy/templates/auth/password_reset_done.html @@ -0,0 +1,11 @@ +{% extends 'base-with-header.html' %} +{% load widget_tweaks %} +{% load static %} +{% block content %} +
+ +
+{% endblock %} diff --git a/bloggy/templates/auth/register.html b/bloggy/templates/auth/register.html index 13f0f70..ebd08d8 100644 --- a/bloggy/templates/auth/register.html +++ b/bloggy/templates/auth/register.html @@ -39,7 +39,7 @@

Create your free account!

{{ form.password2|add_class:"form-control" }} {{ form.password2.errors }}
- {{ form.honeypot|add_class:"hidden" }} + {{ form.honeypot|add_class:"d-none" }}