├── .bumpversion.cfg ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── addon.json ├── cms_articles ├── __init__.py ├── admin │ ├── __init__.py │ ├── article.py │ ├── attribute.py │ ├── category.py │ └── forms.py ├── api.py ├── apps.py ├── archive.py ├── article_rendering.py ├── cms_apps.py ├── cms_plugins.py ├── cms_toolbars.py ├── conf │ ├── __init__.py │ └── default_settings.py ├── import_wordpress │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── cms_import_wordpress.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_update.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── cms_articles │ │ │ └── import_wordpress │ │ │ ├── cms_import.html │ │ │ └── form.html │ └── utils.py ├── locale │ └── cs │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── migrations │ ├── 0001_initial.py │ ├── 0002_remove_title_excerpt.py │ ├── 0003_description_image.py │ ├── 0004_categories.py │ ├── 0005_attributes.py │ ├── 0006_order_date.py │ ├── 0007_plugins.py │ ├── 0008_cms_3_4.py │ ├── 0009_number_min_value.py │ ├── 0010_remove_article_revision_id.py │ ├── 0011_attribute_site.py │ ├── 0012_protect_keys.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── article.py │ ├── attribute.py │ ├── category.py │ ├── managers.py │ ├── plugins.py │ ├── query.py │ └── title.py ├── search_indexes.py ├── signals │ ├── __init__.py │ ├── article.py │ ├── plugins.py │ └── title.py ├── static │ └── cms_articles │ │ ├── css │ │ └── changelist.css │ │ └── js │ │ └── changelist.js ├── templates │ ├── admin │ │ └── cms_articles │ │ │ ├── article │ │ │ ├── change_form.html │ │ │ ├── change_list_lang.html │ │ │ └── change_list_preview.html │ │ │ └── article_changelist.html │ └── cms_articles │ │ ├── article │ │ └── default.html │ │ ├── article_preview.html │ │ ├── articles │ │ └── default.html │ │ ├── base.html │ │ └── default.html ├── templatetags │ ├── __init__.py │ └── cms_articles.py ├── tests │ ├── __init__.py │ ├── fixtures.py │ ├── settings.py │ ├── templates │ │ └── default.html │ └── test_api.py ├── urls.py ├── utils │ ├── __init__.py │ ├── article.py │ └── placeholder.py └── views.py ├── messages ├── poetry.lock ├── pyproject.toml ├── pytest.ini └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.1.5 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:pyproject.toml] 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | .coverage 4 | .tox 5 | .vscode 6 | build 7 | dist 8 | *.egg-info 9 | *~ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Jakub Dorňák, Batiste Bieler 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of django-cms-articles nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include */locale * 4 | recursive-include */static * 5 | recursive-include */templates * 6 | recursive-exclude * *.py[co] 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-cms-articles 2 | the best django CMS application for managing articles 3 | 4 | This application provides full featured articles management for django CMS. 5 | It is heavily inspired by (and partially copied from) the page management in django CMS itself. 6 | 7 | ## Features 8 | 9 | * intuitive admin UI inspired by django CMS page UI 10 | * intuitive front-end editing using placeholders and toolbar menu 11 | * supports multiple languages (the same way as django CMS does) 12 | * publisher workflow from django CMS 13 | * flexible plugins to render article outside django CMS page 14 | 15 | ## Installation and usage 16 | 17 | Installation and usage is quite traightforward. 18 | * install (using pip) django-cms-articles 19 | * add "cms_articles" into your settings.INSTALLED_APPS 20 | * check cms_articles.conf.default_settings for values you may want to override in your settings 21 | * add "Articles Category" apphook to any django CMS page, which should act as category for articles 22 | * add "Articles" plugin to placeholder of your choice to show articles belonging to that page / category 23 | 24 | ## Bugs and Feature requests 25 | 26 | Should you encounter any bug or have some feature request, 27 | create an issue at https://github.com/misli/django-cms-articles/issues. 28 | -------------------------------------------------------------------------------- /addon.json: -------------------------------------------------------------------------------- 1 | { 2 | "package-name": "django-cms-articles", 3 | "installed-apps": [ 4 | "cms_articles" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /cms_articles/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "cms_articles.apps.CMSArticlesConfig" 2 | -------------------------------------------------------------------------------- /cms_articles/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import article, attribute, category 2 | 3 | # use all imports 4 | (article, attribute, category) 5 | -------------------------------------------------------------------------------- /cms_articles/admin/article.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import re 3 | import sys 4 | import warnings 5 | from threading import local 6 | from urllib.parse import unquote 7 | 8 | from cms.admin.placeholderadmin import PlaceholderAdminMixin 9 | from cms.constants import PUBLISHER_STATE_PENDING 10 | from cms.models import CMSPlugin, StaticPlaceholder 11 | from cms.utils import get_language_from_request 12 | from cms.utils.conf import get_cms_setting 13 | from cms.utils.i18n import force_language, get_language_list, get_language_object, get_language_tuple 14 | from cms.utils.urlutils import admin_reverse 15 | from django.contrib import admin, messages 16 | from django.contrib.admin.models import CHANGE, LogEntry 17 | from django.contrib.admin.utils import get_deleted_objects 18 | from django.contrib.contenttypes.models import ContentType 19 | from django.core.exceptions import PermissionDenied, ValidationError 20 | from django.db import router, transaction 21 | from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect 22 | from django.shortcuts import get_object_or_404, render 23 | from django.template.defaultfilters import escape 24 | from django.template.loader import get_template 25 | from django.urls import path 26 | from django.utils.decorators import method_decorator 27 | from django.utils.encoding import force_str 28 | from django.utils.translation import gettext_lazy as _ 29 | from django.views.decorators.http import require_POST 30 | 31 | from ..api import add_content 32 | from ..conf import settings 33 | from ..models import Article, Title 34 | from .forms import ArticleCreateForm, ArticleForm 35 | 36 | require_POST = method_decorator(require_POST) 37 | 38 | _thread_locals = local() 39 | 40 | 41 | class ArticleAdmin(PlaceholderAdminMixin, admin.ModelAdmin): 42 | change_list_template = "admin/cms_articles/article_changelist.html" 43 | search_fields = ("=id", "title_set__slug", "title_set__title", "title_set__description") 44 | list_display = ("__str__", "order_date", "preview_link") + tuple( 45 | "lang_{}".format(lang) for lang in get_language_list() 46 | ) 47 | list_filter = ["tree", "attributes", "categories", "template", "changed_by"] 48 | date_hierarchy = "order_date" 49 | filter_horizontal = ["attributes", "categories"] 50 | 51 | preview_template = "admin/cms_articles/article/change_list_preview.html" 52 | 53 | def preview_link(self, obj): 54 | return get_template(self.preview_template).render( 55 | { 56 | "article": obj, 57 | "lang": _thread_locals.language, 58 | "request": _thread_locals.request, 59 | } 60 | ) 61 | 62 | preview_link.short_description = _("Show") 63 | preview_link.allow_tags = True 64 | 65 | lang_template = "admin/cms_articles/article/change_list_lang.html" 66 | 67 | def __getattr__(self, name): 68 | if name.startswith("lang_"): 69 | lang = name[len("lang_") :] 70 | 71 | def lang_dropdown(obj): 72 | return get_template(self.lang_template).render( 73 | { 74 | "article": obj, 75 | "lang": lang, 76 | "language": _thread_locals.language, 77 | "has_change_permission": obj.has_change_permission(_thread_locals.request), 78 | "has_publish_permission": obj.has_publish_permission(_thread_locals.request), 79 | "request": _thread_locals.request, 80 | } 81 | ) 82 | 83 | lang_dropdown.short_description = lang 84 | lang_dropdown.allow_tags = True 85 | return lang_dropdown 86 | raise AttributeError(name) 87 | 88 | def get_fieldsets(self, request, obj=None): 89 | language_dependent = [ 90 | "title", 91 | "slug", 92 | "description", 93 | "content", 94 | "page_title", 95 | "menu_title", 96 | "meta_description", 97 | "image", 98 | ] 99 | if obj: 100 | language_dependent.remove("content") 101 | return [ 102 | (None, {"fields": ["tree", "template"]}), 103 | (_("Language dependent settings"), {"fields": language_dependent}), 104 | ( 105 | _("Other settings"), 106 | {"fields": ["attributes", "categories", "publication_date", "publication_end_date", "login_required"]}, 107 | ), 108 | ] 109 | 110 | def get_urls(self): 111 | """Get the admin urls""" 112 | info = "%s_%s" % (self.model._meta.app_label, self.model._meta.model_name) 113 | 114 | def make_path(regex, fn): 115 | return path(regex, self.admin_site.admin_view(fn), name="%s_%s" % (info, fn.__name__)) 116 | 117 | url_patterns = [ 118 | make_path("/delete-translation/", self.delete_translation), 119 | make_path("//publish/", self.publish_article), 120 | make_path("//unpublish/", self.unpublish), 121 | make_path("//preview/", self.preview_article), 122 | ] 123 | 124 | url_patterns += super().get_urls() 125 | return url_patterns 126 | 127 | def get_queryset(self, request): 128 | return ( 129 | super() 130 | .get_queryset(request) 131 | .filter( 132 | tree__node__site_id=settings.SITE_ID, 133 | publisher_is_draft=True, 134 | ) 135 | ) 136 | 137 | def save_model(self, request, obj, form, change): 138 | new = obj.id is None 139 | super().save_model(request, obj, form, change) 140 | Title.objects.set_or_create(request, obj, form, form.cleaned_data["language"]) 141 | if new and form.cleaned_data["content"]: 142 | add_content( 143 | obj, 144 | language=form.cleaned_data["language"], 145 | slot=settings.CMS_ARTICLES_SLOT, 146 | content=form.cleaned_data["content"], 147 | ) 148 | 149 | SLUG_REGEXP = re.compile(settings.CMS_ARTICLES_SLUG_REGEXP) 150 | 151 | def get_form(self, request, obj=None, **kwargs): 152 | """ 153 | Get ArticleForm for the Article model and modify its fields depending on 154 | the request. 155 | """ 156 | language = get_language_from_request(request) 157 | form = super().get_form(request, obj, form=(obj and ArticleForm or ArticleCreateForm), **kwargs) 158 | # get_form method operates by overriding initial fields value which 159 | # may persist across invocation. Code below deepcopies fields definition 160 | # to avoid leaks 161 | for field in tuple(form.base_fields.keys()): 162 | form.base_fields[field] = copy.deepcopy(form.base_fields[field]) 163 | 164 | if "language" in form.base_fields: 165 | form.base_fields["language"].initial = language 166 | 167 | if obj: 168 | title_obj = obj.get_title_obj(language=language, fallback=False, force_reload=True) 169 | 170 | if hasattr(title_obj, "id"): 171 | for name in ("title", "description", "page_title", "menu_title", "meta_description", "image"): 172 | if name in form.base_fields: 173 | form.base_fields[name].initial = getattr(title_obj, name) 174 | try: 175 | slug = self.SLUG_REGEXP.search(title_obj.slug).groups()[settings.CMS_ARTICLES_SLUG_GROUP_INDEX] 176 | except AttributeError: 177 | warnings.warn( 178 | "Failed to parse slug from CMS_ARTICLES_SLUG_REGEXP. " 179 | "It probably doesn't correspond to CMS_ARTICLES_SLUG_FORMAT." 180 | ) 181 | slug = title_obj.slug 182 | form.base_fields["slug"].initial = slug 183 | 184 | return form 185 | 186 | def get_unihandecode_context(self, language): 187 | if language[:2] in get_cms_setting("UNIHANDECODE_DECODERS"): 188 | uhd_lang = language[:2] 189 | else: 190 | uhd_lang = get_cms_setting("UNIHANDECODE_DEFAULT_DECODER") 191 | uhd_host = get_cms_setting("UNIHANDECODE_HOST") 192 | uhd_version = get_cms_setting("UNIHANDECODE_VERSION") 193 | if uhd_lang and uhd_host and uhd_version: 194 | uhd_urls = [ 195 | "%sunihandecode-%s.core.min.js" % (uhd_host, uhd_version), 196 | "%sunihandecode-%s.%s.min.js" % (uhd_host, uhd_version, uhd_lang), 197 | ] 198 | else: 199 | uhd_urls = [] 200 | return {"unihandecode_lang": uhd_lang, "unihandecode_urls": uhd_urls} 201 | 202 | def changelist_view(self, request, extra_context=None): 203 | _thread_locals.request = request 204 | _thread_locals.language = get_language_from_request(request) 205 | return super().changelist_view(request, extra_context=extra_context) 206 | 207 | def add_view(self, request, form_url="", extra_context=None): 208 | extra_context = self.update_language_tab_context(request, context=extra_context) 209 | extra_context.update(self.get_unihandecode_context(extra_context["language"])) 210 | return super().add_view(request, form_url, extra_context=extra_context) 211 | 212 | def change_view(self, request, object_id, form_url="", extra_context=None): 213 | extra_context = self.update_language_tab_context(request, context=extra_context) 214 | language = extra_context["language"] 215 | extra_context.update(self.get_unihandecode_context(language)) 216 | response = super().change_view(request, object_id, form_url=form_url, extra_context=extra_context) 217 | if language and response.status_code == 302 and response.headers["location"][1] == request.path_info: 218 | location = response.headers["location"] 219 | response.headers["location"] = (location[0], "%s?language=%s" % (location[1], language)) 220 | return response 221 | 222 | def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None): 223 | # add context variables 224 | filled_languages = [] 225 | if obj: 226 | filled_languages = [t[0] for t in obj.title_set.filter(title__isnull=False).values_list("language")] 227 | allowed_languages = [lang[0] for lang in get_language_tuple()] 228 | context.update( 229 | { 230 | "filled_languages": [lang for lang in filled_languages if lang in allowed_languages], 231 | } 232 | ) 233 | return super().render_change_form(request, context, add, change, form_url, obj) 234 | 235 | def update_language_tab_context(self, request, context=None): 236 | if not context: 237 | context = {} 238 | language = get_language_from_request(request) 239 | languages = get_language_tuple() 240 | context.update( 241 | { 242 | "language": language, 243 | "languages": languages, 244 | "language_tabs": languages, 245 | "show_language_tabs": len(list(languages)) > 1, 246 | } 247 | ) 248 | return context 249 | 250 | @require_POST 251 | @transaction.atomic 252 | def publish_article(self, request, article_id, language): 253 | try: 254 | article = Article.objects.get(id=article_id, publisher_is_draft=True) 255 | except Article.DoesNotExist: 256 | article = None 257 | 258 | # ensure user has permissions to publish this article 259 | if article: 260 | if not self.has_change_permission(request): 261 | return HttpResponseForbidden(_("You do not have permission to publish this article")) 262 | article.publish(language) 263 | statics = request.GET.get("statics", "") 264 | if not statics and not article: 265 | raise Http404("No article or stack found for publishing.") 266 | all_published = True 267 | if statics: 268 | static_ids = statics.split(",") 269 | for pk in static_ids: 270 | static_placeholder = StaticPlaceholder.objects.get(pk=pk) 271 | published = static_placeholder.publish(request, language) 272 | if not published: 273 | all_published = False 274 | if article: 275 | if all_published: 276 | messages.info(request, _("The content was successfully published.")) 277 | LogEntry.objects.log_action( 278 | user_id=request.user.id, 279 | content_type_id=ContentType.objects.get_for_model(Article).pk, 280 | object_id=article_id, 281 | object_repr=article.get_title(language), 282 | action_flag=CHANGE, 283 | ) 284 | else: 285 | messages.warning(request, _("There was a problem publishing your content")) 286 | 287 | if "redirect" in request.GET: 288 | return HttpResponseRedirect(request.GET["redirect"]) 289 | 290 | referrer = request.META.get("HTTP_REFERER", "") 291 | path = admin_reverse("cms_articles_article_changelist") 292 | if request.GET.get("redirect_language"): 293 | path = "%s?language=%s&article_id=%s" % ( 294 | path, 295 | request.GET.get("redirect_language"), 296 | request.GET.get("redirect_article_id"), 297 | ) 298 | if admin_reverse("index") not in referrer: 299 | if all_published: 300 | if article: 301 | if article.get_publisher_state(language) == PUBLISHER_STATE_PENDING: 302 | path = article.get_absolute_url(language, fallback=True) 303 | else: 304 | public_article = Article.objects.get(publisher_public=article.pk) 305 | path = "%s?%s" % ( 306 | public_article.get_absolute_url(language, fallback=True), 307 | get_cms_setting("CMS_TOOLBAR_URL__EDIT_OFF"), 308 | ) 309 | else: 310 | path = "%s?%s" % (referrer, get_cms_setting("CMS_TOOLBAR_URL__EDIT_OFF")) 311 | else: 312 | path = "/?%s" % get_cms_setting("CMS_TOOLBAR_URL__EDIT_OFF") 313 | 314 | return HttpResponseRedirect(path) 315 | 316 | @require_POST 317 | @transaction.atomic 318 | def unpublish(self, request, article_id, language): 319 | """ 320 | Publish or unpublish a language of a article 321 | """ 322 | article = get_object_or_404(self.model, pk=article_id) 323 | if not article.has_publish_permission(request): 324 | return HttpResponseForbidden(_("You do not have permission to unpublish this article")) 325 | if not article.publisher_public_id: 326 | return HttpResponseForbidden(_("This article was never published")) 327 | try: 328 | article.unpublish(language) 329 | message = _('The %(language)s article "%(article)s" was successfully unpublished') % { 330 | "language": get_language_object(language)["name"], 331 | "article": article, 332 | } 333 | messages.info(request, message) 334 | LogEntry.objects.log_action( 335 | user_id=request.user.id, 336 | content_type_id=ContentType.objects.get_for_model(Article).pk, 337 | object_id=article_id, 338 | object_repr=article.get_title(), 339 | action_flag=CHANGE, 340 | change_message=message, 341 | ) 342 | except RuntimeError: 343 | exc = sys.exc_info()[1] 344 | messages.error(request, exc.message) 345 | except ValidationError: 346 | exc = sys.exc_info()[1] 347 | messages.error(request, exc.message) 348 | path = admin_reverse("cms_articles_article_changelist") 349 | if request.GET.get("redirect_language"): 350 | path = "%s?language=%s&article_id=%s" % ( 351 | path, 352 | request.GET.get("redirect_language"), 353 | request.GET.get("redirect_article_id"), 354 | ) 355 | return HttpResponseRedirect(path) 356 | 357 | def delete_translation(self, request, object_id, extra_context=None): 358 | if "language" in request.GET: 359 | language = request.GET["language"] 360 | else: 361 | language = get_language_from_request(request) 362 | 363 | opts = Article._meta 364 | titleopts = Title._meta 365 | app_label = titleopts.app_label 366 | pluginopts = CMSPlugin._meta 367 | 368 | try: 369 | obj = self.get_queryset(request).get(pk=unquote(object_id)) 370 | except self.model.DoesNotExist: 371 | # Don't raise Http404 just yet, because we haven't checked 372 | # permissions yet. We don't want an unauthenticated user to be able 373 | # to determine whether a given object exists. 374 | obj = None 375 | 376 | if not self.has_delete_permission(request, obj): 377 | return HttpResponseForbidden(str(_("You do not have permission to change this article"))) 378 | 379 | if obj is None: 380 | raise Http404( 381 | _("%(name)s object with primary key %(key)r does not exist.") 382 | % {"name": force_str(opts.verbose_name), "key": escape(object_id)} 383 | ) 384 | 385 | if not len(list(obj.get_languages())) > 1: 386 | raise Http404(_("There only exists one translation for this article")) 387 | 388 | titleobj = get_object_or_404(Title, article__id=object_id, language=language) 389 | saved_plugins = CMSPlugin.objects.filter(placeholder__article__id=object_id, language=language) 390 | 391 | using = router.db_for_read(self.model) 392 | kwargs = {"admin_site": self.admin_site, "user": request.user, "using": using} 393 | 394 | deleted_objects, __, perms_needed = get_deleted_objects([titleobj], titleopts, **kwargs)[:3] 395 | to_delete_plugins, __, perms_needed_plugins = get_deleted_objects(saved_plugins, pluginopts, **kwargs)[:3] 396 | 397 | deleted_objects.append(to_delete_plugins) 398 | perms_needed = set(list(perms_needed) + list(perms_needed_plugins)) 399 | 400 | if request.method == "POST": 401 | if perms_needed: 402 | raise PermissionDenied 403 | 404 | message = _("Title and plugins with language %(language)s was deleted") % { 405 | "language": force_str(get_language_object(language)["name"]) 406 | } 407 | self.log_change(request, titleobj, message) 408 | messages.info(request, message) 409 | 410 | titleobj.delete() 411 | for p in saved_plugins: 412 | p.delete() 413 | 414 | public = obj.publisher_public 415 | if public: 416 | public.save() 417 | 418 | if not self.has_change_permission(request, None): 419 | return HttpResponseRedirect(admin_reverse("index")) 420 | return HttpResponseRedirect(admin_reverse("cms_articles_article_changelist")) 421 | 422 | context = { 423 | "title": _("Are you sure?"), 424 | "object_name": force_str(titleopts.verbose_name), 425 | "object": titleobj, 426 | "deleted_objects": deleted_objects, 427 | "perms_lacking": perms_needed, 428 | "opts": opts, 429 | "root_path": admin_reverse("index"), 430 | "app_label": app_label, 431 | } 432 | context.update(extra_context or {}) 433 | request.current_app = self.admin_site.name 434 | return render( 435 | request, 436 | self.delete_confirmation_template 437 | or [ 438 | "admin/%s/%s/delete_confirmation.html" % (app_label, titleopts.object_name.lower()), 439 | "admin/%s/delete_confirmation.html" % app_label, 440 | "admin/delete_confirmation.html", 441 | ], 442 | context, 443 | ) 444 | 445 | def preview_article(self, request, article_id, language): 446 | """Redirecting preview function based on draft_id""" 447 | article = get_object_or_404(self.model, id=article_id) 448 | attrs = "?%s" % get_cms_setting("CMS_TOOLBAR_URL__EDIT_ON") 449 | attrs += "&language=" + language 450 | with force_language(language): 451 | url = article.get_absolute_url(language) + attrs 452 | return HttpResponseRedirect(url) 453 | 454 | 455 | admin.site.register(Article, ArticleAdmin) 456 | -------------------------------------------------------------------------------- /cms_articles/admin/attribute.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from ..conf import settings 4 | from ..models import Attribute 5 | 6 | 7 | @admin.register(Attribute) 8 | class AttributeAdmin(admin.ModelAdmin): 9 | def get_queryset(self, request): 10 | return super().get_queryset(request).filter(site_id=settings.SITE_ID) 11 | -------------------------------------------------------------------------------- /cms_articles/admin/category.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from ..models import Category 4 | 5 | 6 | class CategoryAdmin(admin.ModelAdmin): 7 | search_fields = ("page__title_set__slug", "page__title_set__title") 8 | 9 | 10 | admin.site.register(Category, CategoryAdmin) 11 | -------------------------------------------------------------------------------- /cms_articles/admin/forms.py: -------------------------------------------------------------------------------- 1 | from cms.utils.i18n import get_language_tuple 2 | from django import forms 3 | from django.core.exceptions import ValidationError 4 | from django.forms.utils import ErrorList 5 | from django.utils.text import slugify 6 | from django.utils.timezone import now 7 | from django.utils.translation import gettext_lazy as _ 8 | from djangocms_text_ckeditor.fields import HTMLFormField 9 | 10 | from ..conf import settings 11 | from ..models import Article, Title 12 | from ..utils import is_valid_article_slug 13 | 14 | 15 | class ArticleForm(forms.ModelForm): 16 | language = forms.ChoiceField( 17 | label=_("Language"), choices=get_language_tuple(), help_text=_("The current language of the content fields.") 18 | ) 19 | title = Title._meta.get_field("title").formfield() 20 | slug = Title._meta.get_field("slug").formfield() 21 | description = Title._meta.get_field("description").formfield() 22 | page_title = Title._meta.get_field("page_title").formfield() 23 | menu_title = Title._meta.get_field("menu_title").formfield() 24 | meta_description = Title._meta.get_field("meta_description").formfield() 25 | image = Title._meta.get_field("image").formfield() 26 | 27 | class Meta: 28 | model = Article 29 | fields = ["tree", "template", "login_required"] 30 | 31 | def __init__(self, *args, **kwargs): 32 | super().__init__(*args, **kwargs) 33 | self.fields["language"].widget = forms.HiddenInput() 34 | if self.fields["tree"].widget.choices.queryset.count() == 1: 35 | self.fields["tree"].initial = self.fields["tree"].widget.choices.queryset.first() 36 | self.fields["tree"].widget = forms.HiddenInput() 37 | 38 | def clean(self): 39 | cleaned_data = self.cleaned_data 40 | slug = cleaned_data.get("slug", "") 41 | 42 | article = self.instance 43 | lang = cleaned_data.get("language", None) 44 | # No language, can not go further, but validation failed already 45 | if not lang: 46 | return cleaned_data 47 | tree = self.cleaned_data.get("tree", None) 48 | if tree and not is_valid_article_slug(article, lang, slug): 49 | self._errors["slug"] = ErrorList([_("Another article with this slug already exists")]) 50 | del cleaned_data["slug"] 51 | return cleaned_data 52 | 53 | def clean_slug(self): 54 | slug = slugify(self.cleaned_data["slug"]) 55 | if not slug: 56 | raise ValidationError(_("Slug must not be empty.")) 57 | return settings.CMS_ARTICLES_SLUG_FORMAT.format(now=self.instance.creation_date or now(), slug=slug) 58 | 59 | 60 | class ArticleCreateForm(ArticleForm): 61 | content = HTMLFormField(label=_("Text"), help_text=_("Initial content of the article."), required=False) 62 | 63 | 64 | class PublicationDatesForm(forms.ModelForm): 65 | language = forms.ChoiceField( 66 | label=_("Language"), choices=get_language_tuple(), help_text=_("The current language of the content fields.") 67 | ) 68 | 69 | def __init__(self, *args, **kwargs): 70 | super().__init__(*args, **kwargs) 71 | self.fields["language"].widget = forms.HiddenInput() 72 | 73 | class Meta: 74 | model = Article 75 | fields = ["publication_date", "publication_end_date"] 76 | -------------------------------------------------------------------------------- /cms_articles/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Public Python API to create CMS Articles contents. 4 | 5 | WARNING: None of the functions defined in this module checks for permissions. 6 | You must implement the necessary permission checks in your own code before 7 | calling these methods! 8 | """ 9 | import datetime 10 | 11 | from cms.api import add_plugin 12 | from cms.utils.i18n import get_language_list 13 | from cms.utils.permissions import current_user 14 | from django.db import transaction 15 | from django.template.defaultfilters import slugify 16 | from django.template.loader import get_template 17 | from django.utils.encoding import force_str 18 | from django.utils.timezone import now 19 | from djangocms_text_ckeditor.cms_plugins import TextPlugin 20 | 21 | from .conf import settings 22 | from .models import Article, Title 23 | 24 | 25 | @transaction.atomic 26 | def create_article( 27 | tree, 28 | template, 29 | title, 30 | language, 31 | slug=None, 32 | description=None, 33 | page_title=None, 34 | menu_title=None, 35 | meta_description=None, 36 | created_by=None, 37 | image=None, 38 | publication_date=None, 39 | publication_end_date=None, 40 | published=False, 41 | login_required=False, 42 | creation_date=None, 43 | attributes=[], 44 | categories=[], 45 | ): 46 | """ 47 | Create a CMS Article and it's title for the given language 48 | """ 49 | 50 | # validate tree 51 | tree = tree.get_public_object() 52 | assert tree.application_urls == "CMSArticlesApp" 53 | 54 | # validate template 55 | assert template in [tpl[0] for tpl in settings.CMS_ARTICLES_TEMPLATES] 56 | get_template(template) 57 | 58 | # validate language: 59 | assert language in get_language_list(tree.node.site_id), settings.CMS_LANGUAGES.get(tree.node.site_id) 60 | 61 | # validate publication date 62 | if publication_date: 63 | assert isinstance(publication_date, datetime.date) 64 | 65 | # validate publication end date 66 | if publication_end_date: 67 | assert isinstance(publication_end_date, datetime.date) 68 | 69 | # validate creation date 70 | if not creation_date: 71 | creation_date = publication_date 72 | if creation_date: 73 | assert isinstance(creation_date, datetime.date) 74 | 75 | # get username 76 | if created_by: 77 | try: 78 | username = created_by.get_username() 79 | except Exception: 80 | username = force_str(created_by) 81 | else: 82 | username = "script" 83 | 84 | with current_user(username): 85 | # create article 86 | article = Article.objects.create( 87 | tree=tree, 88 | template=template, 89 | login_required=login_required, 90 | creation_date=creation_date, 91 | publication_date=publication_date, 92 | publication_end_date=publication_end_date, 93 | languages=language, 94 | ) 95 | article.attributes.set(attributes) 96 | article.categories.set(categories) 97 | 98 | # create title 99 | create_title( 100 | article=article, 101 | language=language, 102 | title=title, 103 | slug=slug, 104 | description=description, 105 | page_title=page_title, 106 | menu_title=menu_title, 107 | meta_description=meta_description, 108 | creation_date=creation_date, 109 | image=image, 110 | ) 111 | 112 | # publish article 113 | if published: 114 | article.publish(language) 115 | 116 | return article.reload() 117 | 118 | 119 | @transaction.atomic 120 | def create_title( 121 | article, 122 | language, 123 | title, 124 | slug=None, 125 | description=None, 126 | page_title=None, 127 | menu_title=None, 128 | meta_description=None, 129 | creation_date=None, 130 | image=None, 131 | ): 132 | """ 133 | Create an article title. 134 | """ 135 | # validate article 136 | assert isinstance(article, Article) 137 | 138 | # validate language: 139 | assert language in get_language_list(article.tree.node.site_id) 140 | 141 | # validate creation date 142 | if creation_date: 143 | assert isinstance(creation_date, datetime.date) 144 | 145 | # set default slug: 146 | if not slug: 147 | slug = settings.CMS_ARTICLES_SLUG_FORMAT.format( 148 | now=creation_date or now(), 149 | slug=slugify(title), 150 | ) 151 | 152 | # find unused slug: 153 | base_slug = slug 154 | qs = Title.objects.filter(language=language) 155 | used_slugs = list(s for s in qs.values_list("slug", flat=True) if s.startswith(base_slug)) 156 | i = 1 157 | while slug in used_slugs: 158 | slug = "%s-%s" % (base_slug, i) 159 | i += 1 160 | 161 | # create title 162 | title = Title.objects.create( 163 | article=article, 164 | language=language, 165 | title=title, 166 | slug=slug, 167 | description=description or "", 168 | page_title=page_title, 169 | menu_title=menu_title, 170 | meta_description=meta_description, 171 | image=image, 172 | ) 173 | 174 | return title 175 | 176 | 177 | @transaction.atomic 178 | def add_content(obj, language, slot, content): 179 | """ 180 | Adds a TextPlugin with given content to given slot 181 | """ 182 | placeholder = obj.placeholders.get(slot=slot) 183 | add_plugin(placeholder, TextPlugin, language, body=content) 184 | 185 | 186 | def publish_article(article, language, changed_by=None): 187 | """ 188 | Publish an article. This sets `article.published` to `True` and calls publish() 189 | which does the actual publishing. 190 | """ 191 | article = article.reload() 192 | 193 | # get username 194 | if changed_by: 195 | username = changed_by.get_username() 196 | else: 197 | username = "script" 198 | 199 | with current_user(username): 200 | article.publish(language) 201 | 202 | return article.reload() 203 | -------------------------------------------------------------------------------- /cms_articles/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class CMSArticlesConfig(AppConfig): 6 | name = "cms_articles" 7 | verbose_name = _("django CMS articles") 8 | 9 | def ready(self): 10 | from . import signals 11 | 12 | signals # just use it 13 | -------------------------------------------------------------------------------- /cms_articles/archive.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from datetime import date 3 | 4 | from django.utils.functional import cached_property 5 | 6 | from .conf import settings 7 | 8 | 9 | class Archive: 10 | def __init__(self, articles, request): 11 | self.articles = articles 12 | self.request = request 13 | self.year = self.month = self.day = None 14 | try: 15 | self.year = int(request.GET[settings.CMS_ARTICLES_YEAR_FIELD]) 16 | self.month = int(request.GET[settings.CMS_ARTICLES_MONTH_FIELD]) 17 | self.day = int(request.GET[settings.CMS_ARTICLES_DAY_FIELD]) 18 | except (KeyError, ValueError): 19 | pass 20 | 21 | def filter_articles(self): 22 | articles = self.articles 23 | if self.year: 24 | articles = articles.filter(order_date__year=self.year) 25 | if self.month: 26 | articles = articles.filter(order_date__month=self.month) 27 | if self.day: 28 | articles = articles.filter(order_date__day=self.day) 29 | return articles 30 | 31 | @cached_property 32 | def last(self): 33 | try: 34 | return self.articles.last().order_date 35 | except AttributeError: 36 | return date.today() 37 | 38 | def years(self): 39 | for year in range(date.today().year, self.last.year - 1, -1): 40 | yield YearArchive(year, self) 41 | 42 | @cached_property 43 | def date(self): 44 | if self.year: 45 | return date(self.year, self.month or 1, self.day or 1) 46 | else: 47 | return None 48 | 49 | 50 | class YearArchive: 51 | def __init__(self, year, archive): 52 | self.year = year 53 | self.archive = archive 54 | self.articles = archive.articles.filter(order_date__year=year) 55 | self.active = archive.year == year 56 | 57 | def months(self): 58 | if self.year == date.today().year: 59 | first = date.today().month 60 | else: 61 | first = 12 62 | if self.year == self.archive.last.year: 63 | last = self.archive.last.month 64 | else: 65 | last = 1 66 | for month in range(first, last, -1): 67 | yield MonthArchive(month, self) 68 | 69 | @cached_property 70 | def date(self): 71 | return date(self.year, 1, 1) 72 | 73 | @cached_property 74 | def url(self): 75 | return "{path}?{y}={year}".format( 76 | path=self.archive.request.path, 77 | y=settings.CMS_ARTICLES_YEAR_FIELD, 78 | year=self.year, 79 | ) 80 | 81 | 82 | class MonthArchive: 83 | def __init__(self, month, year_archive): 84 | self.month = month 85 | self.year_archive = year_archive 86 | self.articles = year_archive.articles.filter(order_date__month=month) 87 | self.active = year_archive.archive.month == month 88 | 89 | def days(self): 90 | if self.year == date.today().year and self.month == date.today().month: 91 | first = date.today().day 92 | else: 93 | first = calendar.monthrange(self.year_archive.year, self.month)[1] 94 | if self.year == self.year_archive.archive.last.year and self.month == self.year_archive.archive.last.month: 95 | last = self.year_archive.archive.last.day 96 | else: 97 | last = 1 98 | for day in range(first, last, -1): 99 | yield DayArchive(day, self) 100 | 101 | @cached_property 102 | def date(self): 103 | return date(self.year_archive.year, self.month, 1) 104 | 105 | @cached_property 106 | def url(self): 107 | return "{path}?{y}={year}&{m}={month}".format( 108 | path=self.year_archive.archive.request.path, 109 | y=settings.CMS_ARTICLES_YEAR_FIELD, 110 | m=settings.CMS_ARTICLES_MONTH_FIELD, 111 | year=self.year_archive.year, 112 | month=self.month, 113 | ) 114 | 115 | 116 | class DayArchive: 117 | def __init__(self, day, month_archive): 118 | self.day = day 119 | self.month_archive = month_archive 120 | self.articles = month_archive.articles.filter(order_date__day=day) 121 | self.active = month_archive.year_archive.archive.day == day 122 | 123 | @cached_property 124 | def date(self): 125 | return date(self.month_archive.year_archive.year, self.month_archive.month, self.day) 126 | 127 | @cached_property 128 | def url(self): 129 | return "{path}?{y}={year}&{m}={month}&{d}={day}".format( 130 | path=self.month_archive.year_archive.archive.request.path, 131 | y=settings.CMS_ARTICLES_YEAR_FIELD, 132 | m=settings.CMS_ARTICLES_MONTH_FIELD, 133 | d=settings.CMS_ARTICLES_DAY_FIELD, 134 | year=self.month_archive.year_archive.year, 135 | month=self.month_archive.month, 136 | day=self.day, 137 | ) 138 | -------------------------------------------------------------------------------- /cms_articles/article_rendering.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from cms.cache.page import set_page_cache 3 | from cms.models import Page 4 | from django.template.response import TemplateResponse 5 | 6 | 7 | def render_article(request, article, current_language, slug): 8 | """ 9 | Renders an article 10 | """ 11 | context = {} 12 | context["article"] = article 13 | context["lang"] = current_language 14 | context["current_article"] = article 15 | context["has_change_permissions"] = article.has_change_permission(request) 16 | 17 | response = TemplateResponse(request, article.template, context) 18 | response.add_post_render_callback(set_page_cache) 19 | 20 | # Add headers for X Frame Options - this really should be changed upon moving to class based views 21 | xframe_options = article.tree.get_xframe_options() 22 | # xframe_options can be None if there's no xframe information on the page 23 | # (eg. a top-level page which has xframe options set to "inherit") 24 | if xframe_options == Page.X_FRAME_OPTIONS_INHERIT or xframe_options is None: 25 | # This is when we defer to django's own clickjacking handling 26 | return response 27 | 28 | # We want to prevent django setting this in their middlewear 29 | response.xframe_options_exempt = True 30 | 31 | if xframe_options == Page.X_FRAME_OPTIONS_ALLOW: 32 | # Do nothing, allowed is no header. 33 | return response 34 | elif xframe_options == Page.X_FRAME_OPTIONS_SAMEORIGIN: 35 | response["X-Frame-Options"] = "SAMEORIGIN" 36 | elif xframe_options == Page.X_FRAME_OPTIONS_DENY: 37 | response["X-Frame-Options"] = "DENY" 38 | return response 39 | -------------------------------------------------------------------------------- /cms_articles/cms_apps.py: -------------------------------------------------------------------------------- 1 | from cms.app_base import CMSApp 2 | from cms.apphook_pool import apphook_pool 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class CMSArticlesApp(CMSApp): 7 | name = _("Articles tree") 8 | app_name = "cms_articles" 9 | 10 | def get_urls(self, page=None, language=None, **kwargs): 11 | return ["cms_articles.urls"] 12 | 13 | 14 | apphook_pool.register(CMSArticlesApp) 15 | -------------------------------------------------------------------------------- /cms_articles/cms_plugins.py: -------------------------------------------------------------------------------- 1 | from cms.plugin_base import CMSPluginBase 2 | from cms.plugin_pool import plugin_pool 3 | from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from .archive import Archive 7 | from .conf import settings 8 | from .models import ArticlePlugin, ArticlesCategoryPlugin, ArticlesPlugin 9 | 10 | 11 | class ArticlePlugin(CMSPluginBase): 12 | module = _("Articles") 13 | name = _("Article") 14 | model = ArticlePlugin 15 | cache = False 16 | text_enabled = True 17 | raw_id_fields = ["article"] 18 | 19 | def render(self, context, instance, placeholder): 20 | context.update( 21 | { 22 | "plugin": instance, 23 | "article": instance.get_article(context), 24 | "placeholder": placeholder, 25 | } 26 | ) 27 | return context 28 | 29 | def get_render_template(self, context, instance, placeholder): 30 | return "cms_articles/article/%s.html" % instance.template 31 | 32 | 33 | plugin_pool.register_plugin(ArticlePlugin) 34 | 35 | 36 | class ArticlesPlugin(CMSPluginBase): 37 | module = _("Articles") 38 | name = _("Articles") 39 | model = ArticlesPlugin 40 | cache = False 41 | text_enabled = True 42 | 43 | def render(self, context, instance, placeholder): 44 | # get articles based on plugin settings 45 | articles = instance.get_articles(context) 46 | 47 | # provide archive 48 | archive = Archive(articles, context["request"]) 49 | 50 | # filter articles based on query 51 | articles = archive.filter_articles() 52 | 53 | # paginate articles 54 | paginator = Paginator(articles, instance.number) 55 | try: 56 | articles = paginator.page(context["request"].GET.get(settings.CMS_ARTICLES_PAGE_FIELD, 1)) 57 | except PageNotAnInteger: 58 | # If page is not an integer, deliver first page. 59 | articles = paginator.page(1) 60 | except EmptyPage: 61 | # If page is out of range (e.g. 9999), deliver last page of results. 62 | articles = paginator.page(paginator.num_pages) 63 | articles.page_field = settings.CMS_ARTICLES_PAGE_FIELD 64 | 65 | context.update( 66 | { 67 | "plugin": instance, 68 | "archive": archive, 69 | "articles": articles, 70 | "placeholder": placeholder, 71 | } 72 | ) 73 | return context 74 | 75 | def get_render_template(self, context, instance, placeholder): 76 | return "cms_articles/articles/%s.html" % instance.template 77 | 78 | 79 | plugin_pool.register_plugin(ArticlesPlugin) 80 | 81 | 82 | class ArticlesCategoryPlugin(ArticlesPlugin): 83 | name = _("Articles category") 84 | model = ArticlesCategoryPlugin 85 | 86 | 87 | plugin_pool.register_plugin(ArticlesCategoryPlugin) 88 | -------------------------------------------------------------------------------- /cms_articles/cms_toolbars.py: -------------------------------------------------------------------------------- 1 | from cms.cms_toolbars import ADMIN_MENU_IDENTIFIER 2 | from cms.toolbar.items import SubMenu 3 | from cms.toolbar_base import CMSToolbar 4 | from cms.toolbar_pool import toolbar_pool 5 | from cms.utils.i18n import force_language 6 | from cms.utils.urlutils import add_url_parameters, admin_reverse 7 | from django.urls import reverse 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | 11 | @toolbar_pool.register 12 | class CMSArticlesToolbar(CMSToolbar): 13 | def populate(self): 14 | # Articles item in main menu 15 | admin_menu = self.toolbar.get_or_create_menu(ADMIN_MENU_IDENTIFIER) 16 | position = admin_menu.get_alphabetical_insert_position(_("Articles"), SubMenu) 17 | url = reverse("admin:cms_articles_article_changelist") 18 | admin_menu.add_sideframe_item(_("Articles"), url=url, position=position) 19 | 20 | # Article menu 21 | article_menu = self.toolbar.get_or_create_menu("cms-articles", _("Article")) 22 | self.article = getattr(self.request, "current_article", None) 23 | if self.article: 24 | if self.toolbar.edit_mode_active: 25 | url = "{}?language={}".format( 26 | admin_reverse("cms_articles_article_change", args=(self.article.pk,)), self.current_lang 27 | ) 28 | article_menu.add_modal_item(_("Article Settings"), url=url) 29 | else: 30 | article_menu.add_link_item(_("Edit this article"), url="?edit") 31 | url = "{}?language={}".format(admin_reverse("cms_articles_article_add"), self.current_lang) 32 | if self.request.current_page: 33 | published_current_page = self.request.current_page.get_public_object() 34 | if published_current_page and published_current_page.application_urls == "CMSArticlesApp": 35 | url += "&tree={}".format(published_current_page.id) 36 | article_menu.add_modal_item(_("New Article"), url=url) 37 | 38 | def post_template_populate(self): 39 | if ( 40 | self.toolbar.edit_mode_active 41 | and self.article 42 | and self.article.has_publish_permission(self.request) 43 | and self.article.is_dirty(self.current_lang) 44 | ): 45 | classes = ["cms-btn-action", "cms-btn-publish", "cms-btn-publish-active", "cms-publish-article"] 46 | 47 | title = _("Publish article now") 48 | 49 | params = {} 50 | params["redirect"] = self.request.path_info 51 | 52 | with force_language(self.current_lang): 53 | url = admin_reverse("cms_articles_article_publish_article", args=(self.article.pk, self.current_lang)) 54 | 55 | url = add_url_parameters(url, params) 56 | 57 | self.toolbar.add_button(title, url=url, extra_classes=classes, side=self.toolbar.RIGHT, disabled=False) 58 | 59 | def request_hook(self): 60 | pass 61 | -------------------------------------------------------------------------------- /cms_articles/conf/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as wrapped_settings 2 | 3 | from . import default_settings as default_settings 4 | 5 | 6 | class LazySettings(object): 7 | def __dir__(self): 8 | return dir(wrapped_settings) + dir(default_settings) 9 | 10 | def __getattr__(self, name): 11 | try: 12 | value = getattr(wrapped_settings, name) 13 | except AttributeError: 14 | value = getattr(default_settings, name) 15 | setattr(self, name, value) 16 | return value 17 | 18 | 19 | settings = LazySettings() 20 | -------------------------------------------------------------------------------- /cms_articles/conf/default_settings.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | # by default, use the same templates as for cms pages 4 | CMS_ARTICLES_TEMPLATES = [("cms_articles/default.html", _("Default"))] 5 | 6 | # default slug format 7 | CMS_ARTICLES_SLUG_FORMAT = "{now:%Y-%m}-{slug}" 8 | CMS_ARTICLES_SLUG_REGEXP = r"[0-9]{4}-[0-9]{2}-([^/]+)" 9 | CMS_ARTICLES_SLUG_GROUP_INDEX = 0 10 | 11 | # templates used to render plugin article 12 | CMS_ARTICLES_PLUGIN_ARTICLE_TEMPLATES = [ 13 | ("default", _("Default")), 14 | ] 15 | 16 | # templates used to render plugin articles 17 | CMS_ARTICLES_PLUGIN_ARTICLES_TEMPLATES = [ 18 | ("default", _("Default")), 19 | ] 20 | 21 | # the main slot for initial content to be stored in 22 | CMS_ARTICLES_SLOT = "content" 23 | 24 | CMS_ARTICLES_USE_HAYSTACK = True 25 | 26 | CMS_ARTICLES_PAGE_FIELD = "page" 27 | CMS_ARTICLES_YEAR_FIELD = "year" 28 | CMS_ARTICLES_MONTH_FIELD = "month" 29 | CMS_ARTICLES_DAY_FIELD = "day" 30 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "cms_articles.import_wordpress.apps.CmsArticlesImportWordpressConfig" 2 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin, messages 3 | from django.contrib.admin import helpers 4 | from django.contrib.auth import get_user_model 5 | from django.db.models import Case, Q, QuerySet, When 6 | from django.urls import reverse 7 | from django.db import transaction 8 | from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest 9 | from django.shortcuts import get_object_or_404, render 10 | 11 | # from django.template import RequestContext 12 | from django.utils.safestring import mark_safe 13 | from django.utils.translation import gettext_lazy as _ 14 | 15 | from .forms import CMSImportForm, XMLImportForm 16 | from .models import Author, Category, Item, Options 17 | 18 | User = get_user_model() 19 | 20 | 21 | class AuthorAdmin(admin.ModelAdmin): 22 | search_fields = ["login", "email", "first_name", "last_name"] 23 | list_display = ["login", "email", "first_name", "last_name", "user"] 24 | list_editable = ["user"] 25 | actions = ["create_users", "find_users"] 26 | 27 | def has_add_permission(self, request): 28 | return False 29 | 30 | def create_users(self, request, queryset): 31 | for author in queryset.all(): 32 | if author.user: 33 | continue 34 | try: 35 | author.user = User.objects.create( 36 | username=author.login, 37 | email=author.email, 38 | first_name=author.first_name or "", 39 | last_name=author.last_name or "", 40 | ) 41 | author.save() 42 | except Exception as e: 43 | self.message_user(request, _("Failed to create user {}: {}").format(author.login, e), messages.ERROR) 44 | else: 45 | self.message_user(request, _("Successfully created user {}").format(author.login), messages.SUCCESS) 46 | 47 | create_users.short_description = _("Create users for selected authors") 48 | 49 | def find_users(self, request, queryset): 50 | for author in queryset.all(): 51 | if author.user: 52 | continue 53 | 54 | try: 55 | author.user = User.objects.get(username=author.login) 56 | except User.DoesNotExist: 57 | pass 58 | 59 | if not author.user: 60 | author.user = User.objects.filter(email=author.email).first() 61 | 62 | if not author.user: 63 | author.user = User.objects.filter(first_name=author.first_name, last_name=author.last_name).first() 64 | 65 | if author.user: 66 | author.save() 67 | self.message_user( 68 | request, _("Successfully found user {} for author").format(author.user, author), messages.SUCCESS 69 | ) 70 | else: 71 | self.message_user(request, _("Failed to find user for author {}").format(author), messages.ERROR) 72 | 73 | find_users.short_description = _("Find users for selected authors") 74 | 75 | 76 | admin.site.register(Author, AuthorAdmin) 77 | 78 | 79 | class CategoryAdmin(admin.ModelAdmin): 80 | search_fields = ["=term_id", "name", "slug"] 81 | list_display = ["slug", "cached_name", "category"] 82 | list_editable = ["category"] 83 | ordering = ["cached_name"] 84 | 85 | def has_add_permission(self, request): 86 | return False 87 | 88 | 89 | admin.site.register(Category, CategoryAdmin) 90 | 91 | 92 | class ItemAdmin(admin.ModelAdmin): 93 | search_fields = ["=post_id", "=post_parent", "categories__name", "title"] 94 | list_filter = ["post_type", "status", "categories"] 95 | list_display = [ 96 | "post_id", 97 | "parent_link", 98 | "children_link", 99 | "title_link", 100 | "post_type", 101 | "post_date", 102 | "status", 103 | "imported_link", 104 | ] 105 | actions = ["cms_import"] 106 | 107 | def get_queryset(self, request: HttpRequest) -> QuerySet[Item]: 108 | return ( 109 | super() 110 | .get_queryset(request) 111 | .annotate( 112 | is_imported=Case( 113 | When( 114 | Q(article__isnull=True) & Q(page__isnull=True) & Q(file__isnull=True) & Q(folder__isnull=True), 115 | then=True, 116 | ), 117 | default=False, 118 | ), 119 | ) 120 | ) 121 | 122 | def get_urls(self): 123 | return [ 124 | url( 125 | r"^import/$", 126 | self.import_item, 127 | name="{}_{}_import_item".format( 128 | self.model._meta.app_label, 129 | self.model._meta.model_name, 130 | ), 131 | ), 132 | ] + super().get_urls() 133 | 134 | def save_model(self, request, obj, form, change): 135 | pass 136 | 137 | def save_related(self, request, form, formsets, change): 138 | pass 139 | 140 | def log_addition(self, request, object, message): 141 | pass 142 | 143 | def response_add(self, request, obj, post_url_continue=None): 144 | for error in obj["errors"]: 145 | self.message_user(request, error, messages.ERROR) 146 | for msg in ( 147 | _("Successfullty imported {} authors").format(obj["authors"]), 148 | _("Successfullty imported {} categories").format(obj["categories"]), 149 | _("Successfullty imported {} items").format(obj["items"]), 150 | ): 151 | self.message_user(request, msg, messages.SUCCESS) 152 | return self.response_post_save_add(request, obj) 153 | 154 | def get_form(self, request, obj=None, **kwargs): 155 | if not obj: 156 | kwargs["form"] = XMLImportForm 157 | return super().get_form(request, obj, **kwargs) 158 | 159 | @admin.display(description=_("post parent"), ordering="post_parent") 160 | def parent_link(self, obj): 161 | if obj.post_parent: 162 | return mark_safe( 163 | '{label}'.format( 164 | url=reverse( 165 | "admin:{}_{}_changelist".format( 166 | Item._meta.app_label, 167 | Item._meta.model_name, 168 | ) 169 | ) 170 | + "?post_id__exact={}".format(obj.post_parent), 171 | label=obj.post_parent, 172 | ) 173 | ) 174 | else: 175 | return "" 176 | 177 | @admin.display(description=_("post children")) 178 | def children_link(self, obj): 179 | count = obj.children.count() 180 | if count: 181 | return mark_safe( 182 | '{label}'.format( 183 | url=reverse( 184 | "admin:{}_{}_changelist".format( 185 | Item._meta.app_label, 186 | Item._meta.model_name, 187 | ) 188 | ) 189 | + "?post_parent__exact={}".format(obj.post_id), 190 | label=count, 191 | ) 192 | ) 193 | else: 194 | return "" 195 | 196 | @admin.display(description=_("title"), ordering="title") 197 | def title_link(self, obj): 198 | return mark_safe( 199 | '{title}'.format( 200 | url=obj.guid, 201 | title=obj.title, 202 | ) 203 | ) 204 | 205 | @admin.display(description=_("imported as"), ordering="is_imported") 206 | def imported_link(self, obj): 207 | url = None 208 | if obj.article or obj.page: 209 | url = (obj.article or obj.page).get_absolute_url() 210 | elif obj.file: 211 | url = obj.file.file.url 212 | elif obj.folder: 213 | url = obj.folder.get_admin_directory_listing_url_path() 214 | if url: 215 | return mark_safe( 216 | '{obj}'.format( 217 | obj=obj.article or obj.page or obj.file or obj.folder, 218 | url=url, 219 | ) 220 | ) 221 | else: 222 | return "" 223 | 224 | @admin.action(description=_("Import selected items into CMS")) 225 | def cms_import(self, request, queryset): 226 | if request.POST.get("post", "no") == "yes": 227 | form = CMSImportForm(request.POST) 228 | if form.is_valid(): 229 | return render( 230 | request, 231 | "cms_articles/import_wordpress/cms_import.html", 232 | { 233 | "title": _("Running import"), 234 | "items": queryset, 235 | "options": form.cleaned_data["options"], 236 | "media": self.media, 237 | "opts": self.model._meta, 238 | }, 239 | # context_instance=RequestContext(request), # TODO: delete this line 240 | ) 241 | else: 242 | form = CMSImportForm() 243 | return render( 244 | request, 245 | "cms_articles/import_wordpress/form.html", 246 | { 247 | "title": _("Select predefined import options"), 248 | "queryset": queryset, 249 | "opts": self.model._meta, 250 | "form": form, 251 | "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME, 252 | }, 253 | # context_instance=RequestContext(request), # TODO: delete this line 254 | ) 255 | 256 | @transaction.atomic 257 | def import_item(self, request): 258 | try: 259 | item_id = int(request.GET["item_id"]) 260 | options_id = int(request.GET["options_id"]) 261 | except (KeyError, ValueError): 262 | return HttpResponseBadRequest() 263 | item = get_object_or_404(Item, id=item_id) 264 | options = get_object_or_404(Options, id=options_id) 265 | item.cms_import(options) 266 | return HttpResponse("0", content_type="text/json") 267 | 268 | 269 | admin.site.register(Item, ItemAdmin) 270 | 271 | 272 | class OptionsAdmin(admin.ModelAdmin): 273 | search_fields = ["name"] 274 | save_as = True 275 | 276 | fieldsets = [ 277 | (None, {"fields": ["name"]}), 278 | (_("Global options"), {"fields": ["language"]}), 279 | ( 280 | _("Article specific options"), 281 | { 282 | "fields": [ 283 | "article_tree", 284 | "article_template", 285 | "article_slot", 286 | "article_folder", 287 | "article_redirects", 288 | "article_publish", 289 | ] 290 | }, 291 | ), 292 | ( 293 | _("Page specific options"), 294 | {"fields": ["page_root", "page_template", "page_slot", "page_folder", "page_redirects", "page_publish"]}, 295 | ), 296 | (_("File specific options"), {"fields": ["file_folder"]}), 297 | (_("Gallery specific options"), {"fields": ["gallery_folder"]}), 298 | (_("Slide specific options"), {"fields": ["slide_folder"]}), 299 | ] 300 | 301 | 302 | admin.site.register(Options, OptionsAdmin) 303 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class CmsArticlesImportWordpressConfig(AppConfig): 6 | name = "cms_articles.import_wordpress" 7 | verbose_name = _("django CMS articles - import from WordPress") 8 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .models import Item, Options 5 | from .utils import import_wordpress 6 | 7 | 8 | class XMLImportForm(forms.ModelForm): 9 | wordpress_xml = forms.FileField( 10 | label=_("WordPress XML file"), help_text=_("Select XML file with posts exported from WP.") 11 | ) 12 | 13 | class Meta: 14 | model = Item 15 | fields = [] 16 | 17 | def save(self, *args, **kwargs): 18 | return import_wordpress(self.cleaned_data["wordpress_xml"]) 19 | 20 | 21 | class CMSImportForm(forms.Form): 22 | options = forms.ModelChoiceField( 23 | label=_("Options"), 24 | queryset=Options.objects.order_by("name"), 25 | ) 26 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbsoftware/django-cms-articles/f22eba829172e0a4f4d3c332d60ca4f16277ff10/cms_articles/import_wordpress/management/__init__.py -------------------------------------------------------------------------------- /cms_articles/import_wordpress/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbsoftware/django-cms-articles/f22eba829172e0a4f4d3c332d60ca4f16277ff10/cms_articles/import_wordpress/management/commands/__init__.py -------------------------------------------------------------------------------- /cms_articles/import_wordpress/management/commands/cms_import_wordpress.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from ...utils import import_wordpress 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Import given XML files exported from WordPress" 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("wordpress_xml", nargs="+", type=str) 11 | 12 | def handle(self, *args, **options): 13 | for wordpress_xml in options["wordpress_xml"]: 14 | try: 15 | result = import_wordpress(wordpress_xml) 16 | except Exception as e: 17 | self.stderr.write(self.style.ERROR('Failed to import "{}": {}.'.format(wordpress_xml, e))) 18 | raise CommandError(e) 19 | if result["errors"]: 20 | self.stderr.write(self.style.ERROR("Failed to import {} items.".format(wordpress_xml))) 21 | self.stdout.write( 22 | self.style.SUCCESS( 23 | 'Successfully imported {authors} authors, {categories} categories, and {items} items from "{xml}".'.format( 24 | **result, xml=wordpress_xml 25 | ) 26 | ) 27 | ) 28 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2024-05-06 20:46 2 | 3 | import cms.models.fields 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import filer.fields.folder 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | ("cms", "0022_auto_20180620_1551"), 15 | ("filer", "0017_image__transparent"), 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ("cms_articles", "0012_protect_keys"), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="Author", 23 | fields=[ 24 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 25 | ("author_id", models.IntegerField(unique=True, verbose_name="author id")), 26 | ("login", models.CharField(max_length=255, verbose_name="login name")), 27 | ("email", models.EmailField(blank=True, max_length=254, null=True, verbose_name="email")), 28 | ("first_name", models.CharField(blank=True, max_length=255, null=True, verbose_name="first name")), 29 | ("last_name", models.CharField(blank=True, max_length=255, null=True, verbose_name="last name")), 30 | ( 31 | "user", 32 | models.ForeignKey( 33 | blank=True, 34 | null=True, 35 | on_delete=django.db.models.deletion.SET_NULL, 36 | related_name="+", 37 | to=settings.AUTH_USER_MODEL, 38 | verbose_name="user", 39 | ), 40 | ), 41 | ], 42 | options={ 43 | "verbose_name": "author", 44 | "verbose_name_plural": "authors", 45 | }, 46 | ), 47 | migrations.CreateModel( 48 | name="Category", 49 | fields=[ 50 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 51 | ("term_id", models.IntegerField(unique=True, verbose_name="term id")), 52 | ("name", models.CharField(max_length=255, verbose_name="name")), 53 | ("slug", models.SlugField(verbose_name="slug")), 54 | ("parent", models.CharField(blank=True, max_length=255, null=True, verbose_name="parent slug")), 55 | ("cached_name", models.CharField(blank=True, max_length=512, null=True, verbose_name="name")), 56 | ( 57 | "category", 58 | models.ForeignKey( 59 | blank=True, 60 | null=True, 61 | on_delete=django.db.models.deletion.SET_NULL, 62 | related_name="+", 63 | to="cms_articles.category", 64 | verbose_name="articles category", 65 | ), 66 | ), 67 | ], 68 | options={ 69 | "verbose_name": "category", 70 | "verbose_name_plural": "categories", 71 | }, 72 | ), 73 | migrations.CreateModel( 74 | name="Options", 75 | fields=[ 76 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 77 | ("name", models.CharField(max_length=255, unique=True, verbose_name="name")), 78 | ( 79 | "language", 80 | models.CharField( 81 | help_text="The language of the content fields.", max_length=15, verbose_name="language" 82 | ), 83 | ), 84 | ( 85 | "article_template", 86 | models.CharField( 87 | choices=[("cms_articles/default.html", "Default")], 88 | default="cms_articles/default.html", 89 | max_length=100, 90 | verbose_name="template", 91 | ), 92 | ), 93 | ( 94 | "article_slot", 95 | models.CharField( 96 | default="content", 97 | help_text="The name of placeholder used to create content plugins in.", 98 | max_length=255, 99 | verbose_name="slot", 100 | ), 101 | ), 102 | ( 103 | "article_redirects", 104 | models.BooleanField( 105 | default=True, 106 | help_text="Create django redirects for each article from the old path to the new imported path", 107 | verbose_name="create redirects", 108 | ), 109 | ), 110 | ( 111 | "article_publish", 112 | models.BooleanField(default=False, help_text="Publish imported articles.", verbose_name="publish"), 113 | ), 114 | ( 115 | "page_template", 116 | models.CharField( 117 | choices=[ 118 | ("default.html", "Výchozí"), 119 | ("home.html", "Titulní stránka"), 120 | ("INHERIT", "Inherit the template of the nearest ancestor"), 121 | ], 122 | default="INHERIT", 123 | max_length=100, 124 | verbose_name="template", 125 | ), 126 | ), 127 | ( 128 | "page_slot", 129 | models.CharField( 130 | default="content", 131 | help_text="The name of placeholder used to create content plugins in.", 132 | max_length=255, 133 | verbose_name="slot", 134 | ), 135 | ), 136 | ( 137 | "page_redirects", 138 | models.BooleanField( 139 | default=True, 140 | help_text="Create django redirects for each page from the old path to the new imported path", 141 | verbose_name="create redirects", 142 | ), 143 | ), 144 | ( 145 | "page_publish", 146 | models.BooleanField(default=False, help_text="Publish imported pages.", verbose_name="publish"), 147 | ), 148 | ( 149 | "article_folder", 150 | filer.fields.folder.FilerFolderField( 151 | blank=True, 152 | help_text="Select folder for articles. Subfolder will be created for each article with attachments.", 153 | null=True, 154 | on_delete=django.db.models.deletion.SET_NULL, 155 | related_name="+", 156 | to="filer.folder", 157 | verbose_name="attachments folder", 158 | ), 159 | ), 160 | ( 161 | "article_tree", 162 | models.ForeignKey( 163 | help_text="All posts will be imported as articles in this tree.", 164 | limit_choices_to={ 165 | "application_urls": "CMSArticlesApp", 166 | "node__site_id": 1, 167 | "publisher_is_draft": False, 168 | }, 169 | on_delete=django.db.models.deletion.PROTECT, 170 | related_name="+", 171 | to="cms.page", 172 | verbose_name="tree", 173 | ), 174 | ), 175 | ( 176 | "file_folder", 177 | filer.fields.folder.FilerFolderField( 178 | blank=True, 179 | help_text="Select folder for other attachments.", 180 | null=True, 181 | on_delete=django.db.models.deletion.SET_NULL, 182 | related_name="+", 183 | to="filer.folder", 184 | verbose_name="folder", 185 | ), 186 | ), 187 | ( 188 | "gallery_folder", 189 | filer.fields.folder.FilerFolderField( 190 | blank=True, 191 | help_text="Select folder for galleries. Subfolder will be created for each gallery.", 192 | null=True, 193 | on_delete=django.db.models.deletion.SET_NULL, 194 | related_name="+", 195 | to="filer.folder", 196 | verbose_name="folder", 197 | ), 198 | ), 199 | ( 200 | "page_folder", 201 | filer.fields.folder.FilerFolderField( 202 | blank=True, 203 | help_text="Select folder for pages. Subfolder will be created for each page with attachments.", 204 | null=True, 205 | on_delete=django.db.models.deletion.SET_NULL, 206 | related_name="+", 207 | to="filer.folder", 208 | verbose_name="attachments folder", 209 | ), 210 | ), 211 | ( 212 | "page_root", 213 | cms.models.fields.PageField( 214 | blank=True, 215 | help_text="All pages will be imported as sub-pages of this page.", 216 | null=True, 217 | on_delete=django.db.models.deletion.SET_NULL, 218 | related_name="+", 219 | to="cms.page", 220 | verbose_name="root", 221 | ), 222 | ), 223 | ( 224 | "slide_folder", 225 | filer.fields.folder.FilerFolderField( 226 | blank=True, 227 | help_text="Select folder for slides.", 228 | null=True, 229 | on_delete=django.db.models.deletion.SET_NULL, 230 | related_name="+", 231 | to="filer.folder", 232 | verbose_name="folder", 233 | ), 234 | ), 235 | ], 236 | options={ 237 | "verbose_name": "options", 238 | "verbose_name_plural": "options", 239 | }, 240 | ), 241 | migrations.CreateModel( 242 | name="Item", 243 | fields=[ 244 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 245 | ("title", models.TextField(default="", verbose_name="title")), 246 | ("link", models.CharField(max_length=255, verbose_name="link")), 247 | ("pub_date", models.DateTimeField(verbose_name="publication date")), 248 | ("guid", models.CharField(max_length=255, verbose_name="url")), 249 | ("description", models.TextField(verbose_name="description")), 250 | ("content", models.TextField(verbose_name="content")), 251 | ("excerpt", models.TextField(verbose_name="excerpt")), 252 | ("post_id", models.IntegerField(unique=True, verbose_name="post id")), 253 | ("post_date", models.DateTimeField(verbose_name="post date")), 254 | ("post_name", models.CharField(max_length=255, verbose_name="post name")), 255 | ("status", models.CharField(max_length=20, verbose_name="status")), 256 | ("post_parent", models.IntegerField(verbose_name="parent post id")), 257 | ("post_type", models.CharField(max_length=20, verbose_name="type")), 258 | ("postmeta", models.TextField(verbose_name="metadata")), 259 | ( 260 | "article", 261 | models.OneToOneField( 262 | blank=True, 263 | null=True, 264 | on_delete=django.db.models.deletion.SET_NULL, 265 | related_name="+", 266 | to="cms_articles.article", 267 | verbose_name="imported article", 268 | ), 269 | ), 270 | ( 271 | "categories", 272 | models.ManyToManyField(blank=True, related_name="kategorie", to="import_wordpress.Category"), 273 | ), 274 | ( 275 | "created_by", 276 | models.ForeignKey( 277 | on_delete=django.db.models.deletion.PROTECT, 278 | to="import_wordpress.author", 279 | verbose_name="created by", 280 | ), 281 | ), 282 | ( 283 | "file", 284 | models.OneToOneField( 285 | blank=True, 286 | null=True, 287 | on_delete=django.db.models.deletion.SET_NULL, 288 | related_name="+", 289 | to="filer.file", 290 | verbose_name="imported file", 291 | ), 292 | ), 293 | ( 294 | "folder", 295 | models.ForeignKey( 296 | blank=True, 297 | null=True, 298 | on_delete=django.db.models.deletion.SET_NULL, 299 | related_name="+", 300 | to="filer.folder", 301 | verbose_name="attachments folder", 302 | ), 303 | ), 304 | ( 305 | "page", 306 | models.OneToOneField( 307 | blank=True, 308 | null=True, 309 | on_delete=django.db.models.deletion.SET_NULL, 310 | related_name="+", 311 | to="cms.page", 312 | verbose_name="imported page", 313 | ), 314 | ), 315 | ], 316 | options={ 317 | "verbose_name": "item", 318 | "verbose_name_plural": "items", 319 | }, 320 | ), 321 | ] 322 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/migrations/0002_update.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2024-05-06 23:18 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("import_wordpress", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="item", 15 | name="created_by", 16 | field=models.ForeignKey( 17 | blank=True, 18 | null=True, 19 | on_delete=django.db.models.deletion.SET_NULL, 20 | to="import_wordpress.author", 21 | verbose_name="created by", 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="item", 26 | name="link", 27 | field=models.CharField(max_length=512, verbose_name="link"), 28 | ), 29 | migrations.AlterField( 30 | model_name="item", 31 | name="pub_date", 32 | field=models.DateTimeField(blank=True, null=True, verbose_name="publication date"), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbsoftware/django-cms-articles/f22eba829172e0a4f4d3c332d60ca4f16277ff10/cms_articles/import_wordpress/migrations/__init__.py -------------------------------------------------------------------------------- /cms_articles/import_wordpress/models.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | 3 | from cms.api import create_page 4 | from cms.models import Page 5 | from cms.models.fields import PageField 6 | from django.core.files import File as DjangoFile 7 | from django.core.files.temp import NamedTemporaryFile 8 | from django.db import models 9 | from django.utils.functional import cached_property 10 | from django.utils.text import slugify 11 | from django.utils.timezone import now 12 | from django.utils.translation import gettext_lazy as _ 13 | from filer.fields.folder import FilerFolderField 14 | from filer.models import File, Folder 15 | 16 | from cms_articles.api import add_content, create_article, publish_article 17 | from cms_articles.conf import settings 18 | 19 | from .utils import create_redirect 20 | 21 | from urllib.request import urlopen 22 | from urllib.parse import quote, urlparse 23 | 24 | 25 | class Author(models.Model): 26 | author_id = models.IntegerField(_("author id"), unique=True) 27 | login = models.CharField(_("login name"), max_length=255) 28 | email = models.EmailField(_("email"), blank=True, null=True) 29 | first_name = models.CharField(_("first name"), max_length=255, blank=True, null=True) 30 | last_name = models.CharField(_("last name"), max_length=255, blank=True, null=True) 31 | user = models.ForeignKey( 32 | settings.AUTH_USER_MODEL, 33 | verbose_name=_("user"), 34 | related_name="+", 35 | on_delete=models.SET_NULL, 36 | blank=True, 37 | null=True, 38 | ) 39 | 40 | def __str__(self): 41 | return "{}".format(self.login) 42 | 43 | class Meta: 44 | verbose_name = _("author") 45 | verbose_name_plural = _("authors") 46 | 47 | 48 | class Category(models.Model): 49 | term_id = models.IntegerField(_("term id"), unique=True) 50 | name = models.CharField(_("name"), max_length=255) 51 | slug = models.SlugField(_("slug")) 52 | parent = models.CharField(_("parent slug"), max_length=255, blank=True, null=True) 53 | cached_name = models.CharField(_("name"), max_length=512, blank=True, null=True) 54 | category = models.ForeignKey( 55 | "cms_articles.Category", 56 | verbose_name=_("articles category"), 57 | related_name="+", 58 | on_delete=models.SET_NULL, 59 | blank=True, 60 | null=True, 61 | ) 62 | 63 | def __str__(self): 64 | try: 65 | parent = Category.objects.get(slug=self.parent) 66 | except Category.DoesNotExist: 67 | parent = None 68 | if parent: 69 | name = "{} / {}".format(parent.name, self.name) 70 | else: 71 | name = "{}".format(self.name) 72 | if name != self.cached_name: 73 | self.cached_name = name 74 | self.save() 75 | return name 76 | 77 | class Meta: 78 | verbose_name = _("category") 79 | verbose_name_plural = _("categories") 80 | 81 | 82 | class Item(models.Model): 83 | title = models.TextField(_("title"), default="") 84 | link = models.CharField(_("link"), max_length=512) 85 | pub_date = models.DateTimeField(_("publication date"), blank=True, null=True) 86 | created_by = models.ForeignKey( 87 | Author, verbose_name=_("created by"), on_delete=models.SET_NULL, blank=True, null=True 88 | ) 89 | guid = models.CharField(_("url"), max_length=255) 90 | description = models.TextField(_("description")) 91 | content = models.TextField(_("content")) 92 | excerpt = models.TextField(_("excerpt")) 93 | post_id = models.IntegerField(_("post id"), unique=True) 94 | post_date = models.DateTimeField(_("post date")) 95 | post_name = models.CharField(_("post name"), max_length=255) 96 | status = models.CharField(_("status"), max_length=20) 97 | post_parent = models.IntegerField(_("parent post id")) 98 | post_type = models.CharField(_("type"), max_length=20) 99 | categories = models.ManyToManyField(Category, _("categories"), blank=True) 100 | postmeta = models.TextField(_("metadata")) 101 | article = models.OneToOneField( 102 | "cms_articles.Article", 103 | verbose_name=_("imported article"), 104 | related_name="+", 105 | on_delete=models.SET_NULL, 106 | blank=True, 107 | null=True, 108 | ) 109 | page = models.OneToOneField( 110 | "cms.Page", verbose_name=_("imported page"), related_name="+", on_delete=models.SET_NULL, blank=True, null=True 111 | ) 112 | file = models.OneToOneField( 113 | File, verbose_name=_("imported file"), related_name="+", on_delete=models.SET_NULL, blank=True, null=True 114 | ) 115 | folder = models.ForeignKey( 116 | Folder, verbose_name=_("attachments folder"), related_name="+", on_delete=models.SET_NULL, blank=True, null=True 117 | ) 118 | 119 | def __str__(self): 120 | return "{}".format(self.title) 121 | 122 | class Meta: 123 | verbose_name = _("item") 124 | verbose_name_plural = _("items") 125 | 126 | @property 127 | def children(self): 128 | return Item.objects.filter(post_parent=self.post_id) 129 | 130 | @cached_property 131 | def parent(self): 132 | if self.post_parent: 133 | try: 134 | return Item.objects.get(post_id=self.post_parent) 135 | except Item.DoesNotExist: 136 | pass 137 | return None 138 | 139 | @cached_property 140 | def meta(self): 141 | return loads(self.postmeta) 142 | 143 | def cms_import(self, options): 144 | obj = None 145 | if self.post_type == "post": 146 | obj = self.get_or_import_article(options) 147 | elif self.post_type == "page": 148 | obj = self.get_or_import_page(options) 149 | elif self.post_type == "attachment": 150 | obj = self.get_or_import_file(options) 151 | # also import children 152 | for child in self.children.all(): 153 | try: 154 | child.cms_import(options) 155 | except Exception as e: 156 | pass 157 | return obj 158 | 159 | def get_or_import_article(self, options): 160 | assert self.post_type == "post" 161 | if self.article: 162 | return self.article 163 | # import thumbnail 164 | image = None 165 | if "_thumbnail_id" in self.meta: 166 | image_item = Item.objects.get(post_id=int(self.meta["_thumbnail_id"])) 167 | try: 168 | image = image_item.get_or_import_file(options) 169 | except Exception: 170 | pass 171 | self.article = create_article( 172 | tree=options.article_tree, 173 | template=options.article_template, 174 | title=self.title, 175 | language=options.language, 176 | description=self.excerpt, 177 | created_by=self.created_by.user or self.created_by.login, 178 | image=image, 179 | publication_date=self.pub_date, 180 | categories=[c.category for c in self.categories.exclude(category=None)], 181 | ) 182 | if self.post_date: 183 | self.article.creation_date = self.post_date 184 | self.article.save() 185 | content = "\n".join("

{}

".format(p) for p in self.content.split("\n\n")) 186 | add_content(self.article, language=options.language, slot=options.article_slot, content=content) 187 | if options.article_publish: 188 | self.article = publish_article( 189 | article=self.article, 190 | language=options.language, 191 | changed_by=self.created_by.user or self.created_by.login, 192 | ) 193 | public = self.article.get_public_object() 194 | public.creation_date = self.pub_date or now() 195 | public.save() 196 | if options.article_redirects: 197 | create_redirect(self.link, self.article.get_absolute_url()) 198 | self.save() 199 | return self.article 200 | 201 | def get_or_import_page(self, options): 202 | assert self.post_type == "page" 203 | if self.page: 204 | return self.page 205 | # import parent page first 206 | if self.parent: 207 | parent = self.parent.get_or_import_page(options) 208 | else: 209 | parent = options.page_root 210 | # get valid slug 211 | slug = self.post_name or slugify(self.title) 212 | assert slug 213 | # handle existing page 214 | self.page = Page.objects.filter(node__parent=parent.node, title_set__slug=slug).first() 215 | if self.page: 216 | self.save() 217 | return self.page 218 | # create new page 219 | self.page = create_page( 220 | template=options.page_template, 221 | language=options.language, 222 | title=self.title, 223 | slug=slug, 224 | meta_description=None, 225 | created_by=self.created_by.user or self.created_by.login, 226 | parent=parent, 227 | publication_date=self.pub_date, 228 | ) 229 | self.page.creation_date = self.post_date 230 | self.page.save() 231 | content = "\n".join("

{}

".format(p) for p in self.content.split("\n\n")) 232 | add_content(self.page, language=options.language, slot=options.page_slot, content=content) 233 | if options.page_publish: 234 | self.page.publish(options.language) 235 | public = self.page.get_public_object() 236 | public.creation_date = self.pub_date or now() 237 | public.save() 238 | if options.page_redirects: 239 | create_redirect(self.link, self.page.get_absolute_url()) 240 | self.save() 241 | return self.page 242 | 243 | def get_or_import_file(self, options): 244 | from filer.management.commands.import_files import FileImporter 245 | 246 | assert self.post_type == "attachment" 247 | if self.file: 248 | return self.file 249 | # download content into deleted temp_file 250 | parsed_url = urlparse(self.guid) 251 | parsed_url = parsed_url._replace(path=quote(parsed_url.path)) 252 | url = parsed_url.geturl() 253 | temp_file = NamedTemporaryFile(delete=True) 254 | temp_file.write(urlopen(url).read()) 255 | temp_file.flush() 256 | # create DjangoFile object 257 | django_file = DjangoFile(temp_file, name=self.guid.split("/")[-1]) 258 | # choose folder 259 | if self.parent: 260 | folder = self.parent.get_or_create_folder(options) 261 | else: 262 | folder = options.file_folder 263 | # import file 264 | self.file = FileImporter().import_file(file_obj=django_file, folder=folder) 265 | # set date and owner 266 | self.file.created_at = self.post_date 267 | self.file.owner = self.created_by.user 268 | self.file.save() 269 | # return imported file 270 | self.save() 271 | return self.file 272 | 273 | def get_or_create_folder(self, options): 274 | assert self.children.count() > 0 275 | if self.folder: 276 | return self.folder 277 | # do not create sub-folders for slides 278 | if self.post_type == "slide": 279 | self.folder = options.slide_folder 280 | self.save() 281 | return self.folder 282 | parent = options.get_folder(self.post_type) 283 | self.folder, new = Folder.objects.get_or_create(parent=parent, name=self.title) 284 | if new: 285 | self.folder.created_at = self.post_date 286 | self.folder.owner = self.created_by.user 287 | self.folder.save() 288 | self.save() 289 | return self.folder 290 | 291 | 292 | class Options(models.Model): 293 | name = models.CharField(_("name"), max_length=255, unique=True) 294 | 295 | # global options 296 | language = models.CharField(_("language"), max_length=15, help_text=_("The language of the content fields.")) 297 | 298 | # article specific options 299 | article_tree = models.ForeignKey( 300 | Page, 301 | on_delete=models.PROTECT, 302 | verbose_name=_("tree"), 303 | related_name="+", 304 | help_text=_("All posts will be imported as articles in this tree."), 305 | limit_choices_to={ 306 | "publisher_is_draft": False, 307 | "application_urls": "CMSArticlesApp", 308 | "node__site_id": settings.SITE_ID, 309 | }, 310 | ) 311 | article_template = models.CharField( 312 | _("template"), 313 | max_length=100, 314 | choices=settings.CMS_ARTICLES_TEMPLATES, 315 | default=settings.CMS_ARTICLES_TEMPLATES[0][0], 316 | ) 317 | article_slot = models.CharField( 318 | _("slot"), 319 | max_length=255, 320 | default=settings.CMS_ARTICLES_SLOT, 321 | help_text=_("The name of placeholder used to create content plugins in."), 322 | ) 323 | article_folder = FilerFolderField( 324 | verbose_name=_("attachments folder"), 325 | related_name="+", 326 | on_delete=models.SET_NULL, 327 | blank=True, 328 | null=True, 329 | help_text=_("Select folder for articles. Subfolder will be created for each article with attachments."), 330 | ) 331 | article_redirects = models.BooleanField( 332 | _("create redirects"), 333 | default=True, 334 | help_text=_("Create django redirects for each article from the old path to the new imported path"), 335 | ) 336 | article_publish = models.BooleanField(_("publish"), default=False, help_text=_("Publish imported articles.")) 337 | 338 | # page specific options 339 | page_root = PageField( 340 | verbose_name=_("root"), 341 | related_name="+", 342 | on_delete=models.SET_NULL, 343 | blank=True, 344 | null=True, 345 | help_text=_("All pages will be imported as sub-pages of this page."), 346 | ) 347 | page_template = models.CharField( 348 | _("template"), max_length=100, choices=Page.template_choices, default=Page.TEMPLATE_DEFAULT 349 | ) 350 | page_slot = models.CharField( 351 | _("slot"), 352 | max_length=255, 353 | default="content", 354 | help_text=_("The name of placeholder used to create content plugins in."), 355 | ) 356 | page_folder = FilerFolderField( 357 | verbose_name=_("attachments folder"), 358 | related_name="+", 359 | on_delete=models.SET_NULL, 360 | blank=True, 361 | null=True, 362 | help_text=_("Select folder for pages. Subfolder will be created for each page with attachments."), 363 | ) 364 | page_redirects = models.BooleanField( 365 | _("create redirects"), 366 | default=True, 367 | help_text=_("Create django redirects for each page from the old path to the new imported path"), 368 | ) 369 | page_publish = models.BooleanField(_("publish"), default=False, help_text=_("Publish imported pages.")) 370 | 371 | # file specific options 372 | gallery_folder = FilerFolderField( 373 | verbose_name=_("folder"), 374 | related_name="+", 375 | on_delete=models.SET_NULL, 376 | blank=True, 377 | null=True, 378 | help_text=_("Select folder for galleries. Subfolder will be created for each gallery."), 379 | ) 380 | 381 | # file specific options 382 | slide_folder = FilerFolderField( 383 | verbose_name=_("folder"), 384 | related_name="+", 385 | on_delete=models.SET_NULL, 386 | blank=True, 387 | null=True, 388 | help_text=_("Select folder for slides."), 389 | ) 390 | 391 | # file specific options 392 | file_folder = FilerFolderField( 393 | verbose_name=_("folder"), 394 | related_name="+", 395 | on_delete=models.SET_NULL, 396 | blank=True, 397 | null=True, 398 | help_text=_("Select folder for other attachments."), 399 | ) 400 | 401 | def __str__(self): 402 | return "{}".format(self.name) 403 | 404 | class Meta: 405 | verbose_name = _("options") 406 | verbose_name_plural = _("options") 407 | 408 | @cached_property 409 | def folders(self): 410 | return { 411 | "post": self.article_folder, 412 | "page": self.page_folder, 413 | "gallery": self.gallery_folder, 414 | "slide": self.slide_folder, 415 | } 416 | 417 | def get_folder(self, post_type): 418 | return self.folders.get(post_type, self.file_folder) 419 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/templates/cms_articles/import_wordpress/cms_import.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n l10n admin_urls %} 3 | 4 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} cms-import{% endblock %} 5 | 6 | {% block extrastyle %} 7 | {{ block.super }} 8 | 25 | {% endblock %} 26 | 27 | {% block extrahead %} 28 | {{ block.super }} 29 | {{ media.js }} 30 | {% endblock %} 31 | 32 | {% block breadcrumbs %} 33 | 39 | {% endblock %} 40 | 41 | {% block content %} 42 | 43 |
    44 | {% for item in items %} 45 |
  • {{ item.title }} ({{ item.post_type }}): {% trans 'waiting' %}
  • 46 | {% endfor %} 47 |
48 | 49 | 80 | 81 | {% endblock %} 82 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/templates/cms_articles/import_wordpress/form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n l10n admin_urls %} 3 | 4 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} import-selected-confirmation{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | 13 | {% endblock %} 14 | 15 | {% block content %} 16 |
{% csrf_token %} 17 | {% for obj in queryset %} 18 | 19 | {% endfor %} 20 | 21 | 22 | {{ form.as_p }}
23 | 24 | {% trans 'Cancel' %} 25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /cms_articles/import_wordpress/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from json import dumps 3 | from xml.etree.ElementTree import ElementTree 4 | 5 | from cms.utils.compat.dj import is_installed 6 | from dateutil.parser import parse 7 | from django.utils.timezone import make_aware 8 | 9 | from ..conf import settings 10 | 11 | if is_installed("django.contrib.redirects"): 12 | from urllib.parse import urlparse 13 | from django.contrib.redirects.models import Redirect 14 | 15 | def create_redirect(old_url, new_url): 16 | old_path = urlparse(old_url).path 17 | new_path = urlparse(new_url).path 18 | if old_path != "/" and new_path != old_path and len(new_path) <= 200 and len(old_path) <= 200: 19 | return Redirect.objects.update_or_create( 20 | defaults=dict(new_path=new_path), 21 | site_id=settings.SITE_ID, 22 | old_path=old_path, 23 | )[0] 24 | 25 | else: 26 | 27 | def create_redirect(old_url, new_url): 28 | pass 29 | 30 | 31 | def import_wordpress(xmlfile): 32 | from .models import Author, Category, Item 33 | 34 | try: 35 | rss = ElementTree(file=xmlfile).getroot() 36 | assert rss.tag == "rss" 37 | except Exception as e: 38 | raise Exception("Failed to parse file {}: {}".format(xmlfile, e)) 39 | 40 | imported_items = 0 41 | errors = [] 42 | 43 | # import authors 44 | authors = {} 45 | for author in rss.findall("*/{http://wordpress.org/export/1.2/}author"): 46 | author_id = "unknown" 47 | try: 48 | # first of all try to parse author_id for use in potential error messages 49 | author_id = int(author.find("{http://wordpress.org/export/1.2/}author_id").text) 50 | login = author.find("{http://wordpress.org/export/1.2/}author_login").text 51 | email = author.find("{http://wordpress.org/export/1.2/}author_email").text 52 | first_name = author.find("{http://wordpress.org/export/1.2/}author_first_name").text 53 | last_name = author.find("{http://wordpress.org/export/1.2/}author_last_name").text 54 | except Exception as e: 55 | error = "Failed to parse author with author_id {}: {}".format(author_id, e) 56 | logging.warning(error) 57 | errors.append(error) 58 | continue 59 | try: 60 | author = Author.objects.get_or_create( 61 | author_id=author_id, 62 | login=login, 63 | email=email, 64 | first_name=first_name, 65 | last_name=last_name, 66 | )[0] 67 | except Exception as e: 68 | error = "Failed to save author with author_id {}: {}".format(author_id, e) 69 | logging.warning(error) 70 | errors.append(error) 71 | continue 72 | authors[login] = author 73 | 74 | # import categories 75 | categories = {} 76 | for category in rss.findall("*/{http://wordpress.org/export/1.2/}category"): 77 | term_id = "unknown" 78 | try: 79 | # first of all try to parse term_id for use in potential error messages 80 | term_id = int(category.find("{http://wordpress.org/export/1.2/}term_id").text) 81 | name = category.find("{http://wordpress.org/export/1.2/}cat_name").text 82 | slug = category.find("{http://wordpress.org/export/1.2/}category_nicename").text 83 | parent = category.find("{http://wordpress.org/export/1.2/}category_parent").text 84 | except Exception as e: 85 | error = "Failed to parse category with term_id {}: {}".format(term_id, e) 86 | logging.warning(error) 87 | errors.append(error) 88 | raise 89 | try: 90 | category = Category.objects.get_or_create( 91 | term_id=term_id, 92 | name=name, 93 | slug=slug, 94 | parent=parent, 95 | )[0] 96 | except Exception as e: 97 | error = "Failed to save category with term_id {}: {}".format(term_id, e) 98 | logging.warning(error) 99 | errors.append(error) 100 | continue 101 | categories[slug] = category 102 | 103 | # import items 104 | for item in rss.findall("*/item"): 105 | post_id = "unknown" 106 | try: 107 | # first of all try to parse post_id for use in potential error messages 108 | post_id = int(item.find("{http://wordpress.org/export/1.2/}post_id").text) 109 | title = item.find("title").text or "" 110 | link = item.find("link").text or "" 111 | pub_date = item.find("pubDate").text 112 | created_by = item.find("{http://purl.org/dc/elements/1.1/}creator").text 113 | guid = item.find("guid").text 114 | description = item.find("description").text or "" 115 | content = item.find("{http://purl.org/rss/1.0/modules/content/}encoded").text or "" 116 | excerpt = item.find("{http://wordpress.org/export/1.2/excerpt/}encoded").text or "" 117 | post_date = make_aware(parse(item.find("{http://wordpress.org/export/1.2/}post_date").text)) 118 | post_name = item.find("{http://wordpress.org/export/1.2/}post_name").text or "" 119 | status = item.find("{http://wordpress.org/export/1.2/}status").text 120 | post_parent = int(item.find("{http://wordpress.org/export/1.2/}post_parent").text) 121 | post_type = item.find("{http://wordpress.org/export/1.2/}post_type").text 122 | postmeta = dumps( 123 | dict( 124 | ( 125 | pm.find("{http://wordpress.org/export/1.2/}meta_key").text, 126 | pm.find("{http://wordpress.org/export/1.2/}meta_value").text, 127 | ) 128 | for pm in item.findall("{http://wordpress.org/export/1.2/}postmeta") 129 | ) 130 | ) 131 | cats = [ 132 | categories[cat.attrib["nicename"]] 133 | for cat in item.findall("category") 134 | if cat.attrib["nicename"] in categories 135 | ] 136 | except Exception as e: 137 | error = "Failed to parse item with post_id {}: {}".format(post_id, e) 138 | logging.warning(error) 139 | errors.append(error) 140 | continue 141 | try: 142 | item = Item.objects.update_or_create( 143 | post_id=post_id, 144 | defaults=dict( 145 | title=title, 146 | link=link, 147 | pub_date=pub_date and parse(pub_date), 148 | created_by=authors.get(created_by), 149 | guid=guid, 150 | description=description, 151 | content=content, 152 | excerpt=excerpt, 153 | post_id=post_id, 154 | post_date=post_date, 155 | post_name=post_name, 156 | status=status, 157 | post_parent=post_parent, 158 | post_type=post_type, 159 | postmeta=postmeta, 160 | ), 161 | )[0] 162 | item.categories.set(cats) 163 | except Exception as e: 164 | error = "Failed to save item with post_id {}: {}".format(post_id, e) 165 | logging.warning(error) 166 | errors.append(error) 167 | continue 168 | imported_items += 1 169 | return { 170 | "authors": len(authors), 171 | "categories": len(categories), 172 | "items": imported_items, 173 | "errors": errors, 174 | } 175 | -------------------------------------------------------------------------------- /cms_articles/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbsoftware/django-cms-articles/f22eba829172e0a4f4d3c332d60ca4f16277ff10/cms_articles/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /cms_articles/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-04-11 18:21 3 | from __future__ import unicode_literals 4 | 5 | import cms.models.fields 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django.utils.timezone 9 | 10 | from cms_articles.conf import settings 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | initial = True 16 | 17 | dependencies = [ 18 | ("cms", "0013_urlconfrevision"), 19 | ] 20 | 21 | operations = [ 22 | migrations.CreateModel( 23 | name="Article", 24 | fields=[ 25 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 26 | ("created_by", models.CharField(editable=False, max_length=255, verbose_name="created by")), 27 | ("changed_by", models.CharField(editable=False, max_length=255, verbose_name="changed by")), 28 | ("creation_date", models.DateTimeField(auto_now_add=True)), 29 | ("changed_date", models.DateTimeField(auto_now=True)), 30 | ( 31 | "publication_date", 32 | models.DateTimeField( 33 | blank=True, 34 | db_index=True, 35 | help_text='When the article should go live. Status must be "Published" for article to go live.', 36 | null=True, 37 | verbose_name="publication date", 38 | ), 39 | ), 40 | ( 41 | "publication_end_date", 42 | models.DateTimeField( 43 | blank=True, 44 | db_index=True, 45 | help_text="When to expire the article. Leave empty to never expire.", 46 | null=True, 47 | verbose_name="publication end date", 48 | ), 49 | ), 50 | ( 51 | "template", 52 | models.CharField( 53 | choices=settings.CMS_ARTICLES_TEMPLATES, 54 | default=settings.CMS_ARTICLES_TEMPLATES[0][0], 55 | help_text="The template used to render the content.", 56 | max_length=100, 57 | verbose_name="template", 58 | ), 59 | ), 60 | ("login_required", models.BooleanField(default=False, verbose_name="login required")), 61 | ("publisher_is_draft", models.BooleanField(db_index=True, default=True, editable=False)), 62 | ("languages", models.CharField(blank=True, editable=False, max_length=255, null=True)), 63 | ( 64 | "category", 65 | models.ForeignKey( 66 | help_text="The page the article is accessible at.", 67 | on_delete=django.db.models.deletion.CASCADE, 68 | related_name="cms_articles", 69 | to="cms.Page", 70 | verbose_name="category", 71 | ), 72 | ), 73 | ( 74 | "placeholders", 75 | models.ManyToManyField(editable=False, related_name="cms_articles", to="cms.Placeholder"), 76 | ), 77 | ( 78 | "publisher_public", 79 | models.OneToOneField( 80 | editable=False, 81 | null=True, 82 | on_delete=django.db.models.deletion.CASCADE, 83 | related_name="publisher_draft", 84 | to="cms_articles.Article", 85 | ), 86 | ), 87 | ], 88 | options={ 89 | "ordering": ("-creation_date",), 90 | "verbose_name": "article", 91 | "verbose_name_plural": "articles", 92 | "permissions": (("publish_article", "Can publish page"),), 93 | }, 94 | ), 95 | migrations.CreateModel( 96 | name="ArticlePlugin", 97 | fields=[ 98 | ( 99 | "cmsplugin_ptr", 100 | models.OneToOneField( 101 | auto_created=True, 102 | on_delete=django.db.models.deletion.CASCADE, 103 | parent_link=True, 104 | primary_key=True, 105 | serialize=False, 106 | to="cms.CMSPlugin", 107 | ), 108 | ), 109 | ( 110 | "template", 111 | models.CharField( 112 | choices=settings.CMS_ARTICLES_PLUGIN_ARTICLE_TEMPLATES, 113 | default=settings.CMS_ARTICLES_PLUGIN_ARTICLE_TEMPLATES[0][0], 114 | help_text="The template used to render plugin.", 115 | max_length=100, 116 | verbose_name="Template", 117 | ), 118 | ), 119 | ( 120 | "article", 121 | models.ForeignKey( 122 | limit_choices_to={"publisher_is_draft": True}, 123 | on_delete=django.db.models.deletion.CASCADE, 124 | related_name="+", 125 | to="cms_articles.Article", 126 | verbose_name="article", 127 | ), 128 | ), 129 | ], 130 | options={ 131 | "abstract": False, 132 | }, 133 | bases=("cms.cmsplugin",), 134 | ), 135 | migrations.CreateModel( 136 | name="ArticlesPlugin", 137 | fields=[ 138 | ( 139 | "cmsplugin_ptr", 140 | models.OneToOneField( 141 | auto_created=True, 142 | on_delete=django.db.models.deletion.CASCADE, 143 | parent_link=True, 144 | primary_key=True, 145 | serialize=False, 146 | to="cms.CMSPlugin", 147 | ), 148 | ), 149 | ("number", models.IntegerField(default=3, verbose_name="Number of last articles")), 150 | ( 151 | "template", 152 | models.CharField( 153 | choices=settings.CMS_ARTICLES_PLUGIN_ARTICLES_TEMPLATES, 154 | default=settings.CMS_ARTICLES_PLUGIN_ARTICLES_TEMPLATES[0][0], 155 | help_text="The template used to render plugin.", 156 | max_length=100, 157 | verbose_name="Template", 158 | ), 159 | ), 160 | ( 161 | "category", 162 | models.ForeignKey( 163 | blank=True, 164 | help_text="Keep empty to show articles from current page, if current page is a category.", 165 | null=True, 166 | on_delete=django.db.models.deletion.CASCADE, 167 | related_name="+", 168 | to="cms.Page", 169 | verbose_name="category", 170 | ), 171 | ), 172 | ], 173 | options={ 174 | "abstract": False, 175 | }, 176 | bases=("cms.cmsplugin",), 177 | ), 178 | migrations.CreateModel( 179 | name="Title", 180 | fields=[ 181 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 182 | ("language", models.CharField(db_index=True, max_length=15, verbose_name="language")), 183 | ("title", models.CharField(max_length=255, verbose_name="title")), 184 | ( 185 | "page_title", 186 | models.CharField( 187 | blank=True, 188 | help_text="overwrite the title (html title tag)", 189 | max_length=255, 190 | null=True, 191 | verbose_name="title", 192 | ), 193 | ), 194 | ( 195 | "menu_title", 196 | models.CharField( 197 | blank=True, 198 | help_text="overwrite the title in the menu", 199 | max_length=255, 200 | null=True, 201 | verbose_name="title", 202 | ), 203 | ), 204 | ( 205 | "meta_description", 206 | models.TextField( 207 | blank=True, 208 | help_text="The text displayed in search engines.", 209 | max_length=155, 210 | null=True, 211 | verbose_name="description", 212 | ), 213 | ), 214 | ("slug", models.SlugField(max_length=255, verbose_name="slug")), 215 | ( 216 | "creation_date", 217 | models.DateTimeField( 218 | default=django.utils.timezone.now, editable=False, verbose_name="creation date" 219 | ), 220 | ), 221 | ("published", models.BooleanField(blank=True, default=False, verbose_name="is published")), 222 | ("publisher_is_draft", models.BooleanField(db_index=True, default=True, editable=False)), 223 | ("publisher_state", models.SmallIntegerField(db_index=True, default=0, editable=False)), 224 | ( 225 | "article", 226 | models.ForeignKey( 227 | on_delete=django.db.models.deletion.CASCADE, 228 | related_name="title_set", 229 | to="cms_articles.Article", 230 | verbose_name="article", 231 | ), 232 | ), 233 | ( 234 | "excerpt", 235 | cms.models.fields.PlaceholderField( 236 | editable=False, 237 | null=True, 238 | on_delete=django.db.models.deletion.CASCADE, 239 | slotname="excerpt", 240 | to="cms.Placeholder", 241 | ), 242 | ), 243 | ( 244 | "publisher_public", 245 | models.OneToOneField( 246 | editable=False, 247 | null=True, 248 | on_delete=django.db.models.deletion.CASCADE, 249 | related_name="publisher_draft", 250 | to="cms_articles.Title", 251 | ), 252 | ), 253 | ], 254 | ), 255 | migrations.AlterUniqueTogether( 256 | name="title", 257 | unique_together=set([("language", "article")]), 258 | ), 259 | ] 260 | -------------------------------------------------------------------------------- /cms_articles/migrations/0002_remove_title_excerpt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-04-20 12:28 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("cms_articles", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name="title", 17 | name="excerpt", 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /cms_articles/migrations/0003_description_image.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-05-16 12:58 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import djangocms_text_ckeditor.fields 8 | import filer.fields.image 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ("filer", "0001_initial"), 15 | ("cms_articles", "0002_remove_title_excerpt"), 16 | ] 17 | 18 | operations = [ 19 | migrations.AddField( 20 | model_name="title", 21 | name="description", 22 | field=djangocms_text_ckeditor.fields.HTMLField( 23 | blank=True, 24 | default="", 25 | help_text="The text displayed in an articles overview.", 26 | verbose_name="description", 27 | ), 28 | ), 29 | migrations.AddField( 30 | model_name="title", 31 | name="image", 32 | field=filer.fields.image.FilerImageField( 33 | blank=True, 34 | null=True, 35 | on_delete=django.db.models.deletion.CASCADE, 36 | related_name="+", 37 | to="filer.Image", 38 | verbose_name="image", 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="title", 43 | name="menu_title", 44 | field=models.CharField( 45 | blank=True, 46 | help_text="overwrite the title in the articles overview", 47 | max_length=255, 48 | null=True, 49 | verbose_name="menu title", 50 | ), 51 | ), 52 | migrations.AlterField( 53 | model_name="title", 54 | name="meta_description", 55 | field=models.TextField( 56 | blank=True, 57 | help_text="The text displayed in search engines.", 58 | max_length=155, 59 | null=True, 60 | verbose_name="meta description", 61 | ), 62 | ), 63 | migrations.AlterField( 64 | model_name="title", 65 | name="page_title", 66 | field=models.CharField( 67 | blank=True, 68 | help_text="overwrite the title (html title tag)", 69 | max_length=255, 70 | null=True, 71 | verbose_name="page title", 72 | ), 73 | ), 74 | ] 75 | -------------------------------------------------------------------------------- /cms_articles/migrations/0004_categories.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-05-16 14:11 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | from cms_articles.conf import settings 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ("cms", "0001_initial"), 15 | ("cms_articles", "0003_description_image"), 16 | ] 17 | 18 | operations = [ 19 | migrations.RenameField( 20 | model_name="article", 21 | old_name="category", 22 | new_name="tree", 23 | ), 24 | migrations.RenameField( 25 | model_name="articlesplugin", 26 | old_name="category", 27 | new_name="tree", 28 | ), 29 | migrations.AlterField( 30 | model_name="article", 31 | name="tree", 32 | field=models.ForeignKey( 33 | help_text="The page the article is accessible at.", 34 | limit_choices_to={ 35 | "application_urls": "CMSArticlesApp", 36 | "node__site_id": 1, 37 | "publisher_is_draft": False, 38 | }, 39 | on_delete=django.db.models.deletion.CASCADE, 40 | related_name="cms_articles", 41 | to="cms.Page", 42 | verbose_name="tree", 43 | ), 44 | ), 45 | migrations.AlterField( 46 | model_name="articlesplugin", 47 | name="tree", 48 | field=models.ForeignKey( 49 | blank=True, 50 | help_text="Keep empty to show articles from current page, if current page is a tree.", 51 | null=True, 52 | on_delete=django.db.models.deletion.CASCADE, 53 | related_name="+", 54 | to="cms.Page", 55 | verbose_name="tree", 56 | ), 57 | ), 58 | migrations.CreateModel( 59 | name="Category", 60 | fields=[ 61 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 62 | ( 63 | "page", 64 | models.OneToOneField( 65 | limit_choices_to={"node__site_id": 1, "publisher_is_draft": True}, 66 | on_delete=django.db.models.deletion.CASCADE, 67 | related_name="cms_articles_category", 68 | to="cms.Page", 69 | verbose_name="page", 70 | ), 71 | ), 72 | ], 73 | options={ 74 | "verbose_name": "category", 75 | "verbose_name_plural": "categories", 76 | }, 77 | ), 78 | migrations.AddField( 79 | model_name="article", 80 | name="categories", 81 | field=models.ManyToManyField( 82 | blank=True, related_name="articles", to="cms_articles.Category", verbose_name="categories" 83 | ), 84 | ), 85 | migrations.AddField( 86 | model_name="articlesplugin", 87 | name="categories", 88 | field=models.ManyToManyField( 89 | blank=True, 90 | related_name="_articlesplugin_categories_+", 91 | to="cms_articles.Category", 92 | verbose_name="categories", 93 | ), 94 | ), 95 | migrations.CreateModel( 96 | name="ArticlesCategoryPlugin", 97 | fields=[ 98 | ( 99 | "cmsplugin_ptr", 100 | models.OneToOneField( 101 | auto_created=True, 102 | on_delete=django.db.models.deletion.CASCADE, 103 | parent_link=True, 104 | primary_key=True, 105 | serialize=False, 106 | to="cms.CMSPlugin", 107 | ), 108 | ), 109 | ("number", models.IntegerField(default=3, verbose_name="Number of last articles")), 110 | ( 111 | "template", 112 | models.CharField( 113 | choices=settings.CMS_ARTICLES_PLUGIN_ARTICLES_TEMPLATES, 114 | default=settings.CMS_ARTICLES_PLUGIN_ARTICLES_TEMPLATES[0][0], 115 | help_text="The template used to render plugin.", 116 | max_length=100, 117 | verbose_name="Template", 118 | ), 119 | ), 120 | ( 121 | "subcategories", 122 | models.BooleanField( 123 | default=False, 124 | help_text="Check, if you want to include articles from sub-categories of this category.", 125 | verbose_name="include sub-categories", 126 | ), 127 | ), 128 | ], 129 | options={ 130 | "abstract": False, 131 | }, 132 | bases=("cms.cmsplugin",), 133 | ), 134 | ] 135 | -------------------------------------------------------------------------------- /cms_articles/migrations/0005_attributes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-05-27 12:00 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("cms_articles", "0004_categories"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Attribute", 17 | fields=[ 18 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 19 | ("name", models.CharField(max_length=255, verbose_name="name")), 20 | ], 21 | options={ 22 | "verbose_name": "attribute", 23 | "verbose_name_plural": "attributes", 24 | }, 25 | ), 26 | migrations.AddField( 27 | model_name="article", 28 | name="attributes", 29 | field=models.ManyToManyField( 30 | blank=True, related_name="articles", to="cms_articles.Attribute", verbose_name="attributes" 31 | ), 32 | ), 33 | migrations.AddField( 34 | model_name="articlescategoryplugin", 35 | name="attributes", 36 | field=models.ManyToManyField( 37 | blank=True, 38 | related_name="_articlescategoryplugin_attributes_+", 39 | to="cms_articles.Attribute", 40 | verbose_name="attributes", 41 | ), 42 | ), 43 | migrations.AddField( 44 | model_name="articlesplugin", 45 | name="attributes", 46 | field=models.ManyToManyField( 47 | blank=True, 48 | related_name="_articlesplugin_attributes_+", 49 | to="cms_articles.Attribute", 50 | verbose_name="attributes", 51 | ), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /cms_articles/migrations/0006_order_date.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-06-13 14:29 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | def set_order_dates(apps, schema_editor): 9 | Article = apps.get_model("cms_articles", "Article") 10 | 11 | for article in Article.objects.all(): 12 | article.order_date = article.publication_date or article.creation_date 13 | article.save() 14 | 15 | 16 | class Migration(migrations.Migration): 17 | 18 | dependencies = [ 19 | ("cms_articles", "0005_attributes"), 20 | ] 21 | 22 | operations = [ 23 | migrations.AlterModelOptions( 24 | name="article", 25 | options={ 26 | "ordering": ("-order_date",), 27 | "permissions": (("publish_article", "Can publish page"),), 28 | "verbose_name": "article", 29 | "verbose_name_plural": "articles", 30 | }, 31 | ), 32 | migrations.AddField( 33 | model_name="article", 34 | name="order_date", 35 | field=models.DateTimeField(null=True), 36 | ), 37 | migrations.RunPython(set_order_dates), 38 | migrations.AlterField( 39 | model_name="article", 40 | name="order_date", 41 | field=models.DateTimeField(auto_now_add=True, verbose_name="publication or creation time"), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /cms_articles/migrations/0007_plugins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-06-22 12:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | def migrate_plugins(apps, schema_editor): 9 | ArticlesPlugin = apps.get_model("cms_articles", "ArticlesPlugin") 10 | 11 | for p in ArticlesPlugin.objects.all(): 12 | if p.tree: 13 | p.trees.add(p.tree) 14 | 15 | 16 | class Migration(migrations.Migration): 17 | 18 | dependencies = [ 19 | ("cms", "0001_initial"), 20 | ("cms_articles", "0006_order_date"), 21 | ] 22 | 23 | operations = [ 24 | migrations.AddField( 25 | model_name="articlesplugin", 26 | name="trees", 27 | field=models.ManyToManyField( 28 | blank=True, null=True, related_name="_articlesplugin_trees_+", to="cms.Page", verbose_name="trees" 29 | ), 30 | ), 31 | migrations.RunPython(migrate_plugins), 32 | migrations.RemoveField( 33 | model_name="articlesplugin", 34 | name="tree", 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /cms_articles/migrations/0008_cms_3_4.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.9 on 2017-02-25 11:38 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("cms_articles", "0007_plugins"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name="article", 18 | options={ 19 | "ordering": ("-order_date",), 20 | "permissions": (("publish_article", "Can publish article"),), 21 | "verbose_name": "article", 22 | "verbose_name_plural": "articles", 23 | }, 24 | ), 25 | migrations.AddField( 26 | model_name="article", 27 | name="revision_id", 28 | field=models.PositiveIntegerField(default=0, editable=False), 29 | ), 30 | migrations.AlterField( 31 | model_name="articleplugin", 32 | name="cmsplugin_ptr", 33 | field=models.OneToOneField( 34 | auto_created=True, 35 | on_delete=django.db.models.deletion.CASCADE, 36 | parent_link=True, 37 | primary_key=True, 38 | related_name="cms_articles_articleplugin", 39 | serialize=False, 40 | to="cms.CMSPlugin", 41 | ), 42 | ), 43 | migrations.AlterField( 44 | model_name="articlescategoryplugin", 45 | name="cmsplugin_ptr", 46 | field=models.OneToOneField( 47 | auto_created=True, 48 | on_delete=django.db.models.deletion.CASCADE, 49 | parent_link=True, 50 | primary_key=True, 51 | related_name="cms_articles_articlescategoryplugin", 52 | serialize=False, 53 | to="cms.CMSPlugin", 54 | ), 55 | ), 56 | migrations.AlterField( 57 | model_name="articlesplugin", 58 | name="cmsplugin_ptr", 59 | field=models.OneToOneField( 60 | auto_created=True, 61 | on_delete=django.db.models.deletion.CASCADE, 62 | parent_link=True, 63 | primary_key=True, 64 | related_name="cms_articles_articlesplugin", 65 | serialize=False, 66 | to="cms.CMSPlugin", 67 | ), 68 | ), 69 | migrations.AlterField( 70 | model_name="articlesplugin", 71 | name="trees", 72 | field=models.ManyToManyField( 73 | blank=True, 74 | limit_choices_to={ 75 | "application_urls": "CMSArticlesApp", 76 | "node__site_id": 1, 77 | "publisher_is_draft": False, 78 | }, 79 | related_name="_articlesplugin_trees_+", 80 | to="cms.Page", 81 | verbose_name="trees", 82 | ), 83 | ), 84 | ] 85 | -------------------------------------------------------------------------------- /cms_articles/migrations/0009_number_min_value.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.9 on 2017-03-05 07:53 3 | from __future__ import unicode_literals 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("cms_articles", "0008_cms_3_4"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="articlescategoryplugin", 18 | name="number", 19 | field=models.PositiveSmallIntegerField( 20 | default=3, 21 | validators=[django.core.validators.MinValueValidator(1)], 22 | verbose_name="Number of last articles", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="articlesplugin", 27 | name="number", 28 | field=models.PositiveSmallIntegerField( 29 | default=3, 30 | validators=[django.core.validators.MinValueValidator(1)], 31 | verbose_name="Number of last articles", 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /cms_articles/migrations/0010_remove_article_revision_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.13 on 2018-02-12 16:28 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("cms_articles", "0009_number_min_value"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name="article", 17 | name="revision_id", 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /cms_articles/migrations/0011_attribute_site.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.11 on 2020-04-16 20:39 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("sites", "0002_alter_domain_unique"), 12 | ("cms_articles", "0010_remove_article_revision_id"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="attribute", 18 | name="site", 19 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to="sites.Site"), 20 | ), 21 | migrations.AlterUniqueTogether( 22 | name="attribute", 23 | unique_together={("site", "name")}, 24 | ), 25 | migrations.AlterField( 26 | model_name="article", 27 | name="attributes", 28 | field=models.ManyToManyField( 29 | blank=True, 30 | limit_choices_to={"site_id": settings.SITE_ID}, 31 | related_name="articles", 32 | to="cms_articles.Attribute", 33 | verbose_name="attributes", 34 | ), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /cms_articles/migrations/0012_protect_keys.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.11 on 2020-04-17 17:51 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import filer.fields.image 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("cms_articles", "0011_attribute_site"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="article", 18 | name="tree", 19 | field=models.ForeignKey( 20 | help_text="The page the article is accessible at.", 21 | limit_choices_to={ 22 | "application_urls": "CMSArticlesApp", 23 | "node__site_id": 1, 24 | "publisher_is_draft": False, 25 | }, 26 | on_delete=django.db.models.deletion.PROTECT, 27 | related_name="cms_articles", 28 | to="cms.Page", 29 | verbose_name="tree", 30 | ), 31 | ), 32 | migrations.AlterField( 33 | model_name="title", 34 | name="image", 35 | field=filer.fields.image.FilerImageField( 36 | blank=True, 37 | null=True, 38 | on_delete=django.db.models.deletion.PROTECT, 39 | related_name="+", 40 | to=settings.FILER_IMAGE_MODEL, 41 | verbose_name="image", 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /cms_articles/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbsoftware/django-cms-articles/f22eba829172e0a4f4d3c332d60ca4f16277ff10/cms_articles/migrations/__init__.py -------------------------------------------------------------------------------- /cms_articles/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .article import Article 2 | from .attribute import Attribute 3 | from .category import Category 4 | from .plugins import ArticlePlugin, ArticlesCategoryPlugin, ArticlesPlugin 5 | from .title import Title 6 | 7 | (Category, Article, Title, Attribute, ArticlePlugin, ArticlesPlugin, ArticlesCategoryPlugin) 8 | -------------------------------------------------------------------------------- /cms_articles/models/attribute.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sites.models import Site 2 | from django.db import models 3 | from django.utils.encoding import force_str 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from ..conf import settings 7 | 8 | 9 | class Attribute(models.Model): 10 | site = models.ForeignKey(Site, on_delete=models.CASCADE, default=settings.SITE_ID) 11 | name = models.CharField(_("name"), max_length=255) 12 | 13 | class Meta: 14 | app_label = "cms_articles" 15 | unique_together = (("site", "name"),) 16 | verbose_name = _("attribute") 17 | verbose_name_plural = _("attributes") 18 | 19 | def __str__(self): 20 | return force_str(self.name) 21 | -------------------------------------------------------------------------------- /cms_articles/models/category.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from cms.models import Page 4 | from django.db import models 5 | from django.utils.encoding import force_str 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from ..conf import settings 9 | 10 | 11 | class Category(models.Model): 12 | page = models.OneToOneField( 13 | Page, 14 | verbose_name=_("page"), 15 | related_name="cms_articles_category", 16 | on_delete=models.CASCADE, 17 | limit_choices_to={"publisher_is_draft": True, "node__site_id": settings.SITE_ID}, 18 | ) 19 | 20 | class Meta: 21 | app_label = "cms_articles" 22 | verbose_name = _("category") 23 | verbose_name_plural = _("categories") 24 | 25 | def __str__(self): 26 | return " / ".join( 27 | chain( 28 | (force_str(p) for p in self.page.get_ancestor_pages()), 29 | (force_str(self.page),), 30 | ) 31 | ) 32 | -------------------------------------------------------------------------------- /cms_articles/models/managers.py: -------------------------------------------------------------------------------- 1 | from cms.publisher import PublisherManager 2 | from cms.utils.i18n import get_fallback_languages 3 | from django.contrib.sites.models import Site 4 | from django.db.models import Q 5 | 6 | from .query import ArticleQuerySet 7 | 8 | 9 | class ArticleManager(PublisherManager): 10 | """Use draft() and public() methods for accessing the corresponding 11 | instances. 12 | """ 13 | 14 | def get_queryset(self): 15 | """Change standard model queryset to our own.""" 16 | return ArticleQuerySet(self.model) 17 | 18 | def search(self, q, language=None, current_site_only=True): 19 | """Simple search function 20 | 21 | Plugins can define a 'search_fields' tuple similar to ModelAdmin classes 22 | """ 23 | from cms.plugin_pool import plugin_pool 24 | 25 | qs = self.get_queryset() 26 | qs = qs.public() 27 | 28 | if current_site_only: 29 | site = Site.objects.get_current() 30 | qs = qs.filter(tree__site=site) 31 | 32 | qt = Q(title_set__title__icontains=q) 33 | 34 | # find 'searchable' plugins and build query 35 | qp = Q() 36 | plugins = plugin_pool.get_all_plugins() 37 | for plugin in plugins: 38 | cmsplugin = plugin.model 39 | if not (hasattr(cmsplugin, "search_fields") and hasattr(cmsplugin, "cmsplugin_ptr")): 40 | continue 41 | field = cmsplugin.cmsplugin_ptr.field 42 | related_query_name = field.related_query_name() 43 | if related_query_name and not related_query_name.startswith("+"): 44 | for field in cmsplugin.search_fields: 45 | qp |= Q( 46 | **{ 47 | "placeholders__cmsplugin__{0}__{1}__icontains".format( 48 | related_query_name, 49 | field, 50 | ): q 51 | } 52 | ) 53 | if language: 54 | qt &= Q(title_set__language=language) 55 | qp &= Q(cmsplugin__language=language) 56 | 57 | qs = qs.filter(qt | qp) 58 | 59 | return qs.distinct() 60 | 61 | 62 | class TitleManager(PublisherManager): 63 | def get_title(self, article, language, language_fallback=False): 64 | """ 65 | Gets the latest content for a particular article and language. Falls back 66 | to another language if wanted. 67 | """ 68 | try: 69 | title = self.get(language=language, article=article) 70 | return title 71 | except self.model.DoesNotExist: 72 | if language_fallback: 73 | try: 74 | titles = self.filter(article=article) 75 | fallbacks = get_fallback_languages(language) 76 | for lang in fallbacks: 77 | for title in titles: 78 | if lang == title.language: 79 | return title 80 | return None 81 | except self.model.DoesNotExist: 82 | pass 83 | else: 84 | raise 85 | return None 86 | 87 | # created new public method to meet test case requirement and to get a list of titles for published articles 88 | def public(self): 89 | return self.get_queryset().filter(publisher_is_draft=False, published=True) 90 | 91 | def drafts(self): 92 | return self.get_queryset().filter(publisher_is_draft=True) 93 | 94 | def set_or_create(self, request, article, form, language): 95 | """ 96 | set or create a title for a particular article and language 97 | """ 98 | base_fields = [ 99 | "slug", 100 | "title", 101 | "description", 102 | "meta_description", 103 | "page_title", 104 | "menu_title", 105 | "image", 106 | ] 107 | cleaned_data = form.cleaned_data 108 | try: 109 | obj = self.get(article=article, language=language) 110 | except self.model.DoesNotExist: 111 | data = {} 112 | for name in base_fields: 113 | if name in cleaned_data: 114 | data[name] = cleaned_data[name] 115 | data["article"] = article 116 | data["language"] = language 117 | return self.create(**data) 118 | for name in base_fields: 119 | if name in form.base_fields: 120 | value = cleaned_data.get(name, None) 121 | setattr(obj, name, value) 122 | obj.save() 123 | return obj 124 | -------------------------------------------------------------------------------- /cms_articles/models/plugins.py: -------------------------------------------------------------------------------- 1 | from cms.models import CMSPlugin, Page 2 | from django.core.validators import MinValueValidator 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from ..conf import settings 7 | from .article import Article 8 | from .attribute import Attribute 9 | from .category import Category 10 | 11 | 12 | class ArticlePlugin(CMSPlugin): 13 | article = models.ForeignKey( 14 | Article, 15 | verbose_name=_("article"), 16 | related_name="+", 17 | on_delete=models.CASCADE, 18 | limit_choices_to={"publisher_is_draft": True}, 19 | ) 20 | template = models.CharField( 21 | _("Template"), 22 | max_length=100, 23 | choices=settings.CMS_ARTICLES_PLUGIN_ARTICLE_TEMPLATES, 24 | default=settings.CMS_ARTICLES_PLUGIN_ARTICLE_TEMPLATES[0][0], 25 | help_text=_("The template used to render plugin."), 26 | ) 27 | 28 | def __str__(self): 29 | return self.article.get_title() 30 | 31 | def get_article(self, context): 32 | try: 33 | edit_mode = context["request"].toolbar.edit_mode_active 34 | except (AttributeError, KeyError): 35 | edit_mode = False 36 | 37 | if edit_mode: 38 | return self.article 39 | else: 40 | return self.article.get_published_object() 41 | 42 | 43 | class ArticlesPluginBase(CMSPlugin): 44 | number = models.PositiveSmallIntegerField( 45 | _("Number of last articles"), default=3, validators=[MinValueValidator(1)] 46 | ) 47 | template = models.CharField( 48 | _("Template"), 49 | max_length=100, 50 | choices=settings.CMS_ARTICLES_PLUGIN_ARTICLES_TEMPLATES, 51 | default=settings.CMS_ARTICLES_PLUGIN_ARTICLES_TEMPLATES[0][0], 52 | help_text=_("The template used to render plugin."), 53 | ) 54 | attributes = models.ManyToManyField(Attribute, verbose_name=_("attributes"), related_name="+", blank=True) 55 | 56 | class Meta: 57 | abstract = True 58 | 59 | def copy_relations(self, oldinstance): 60 | self.attributes.set(oldinstance.attributes.all()) 61 | 62 | def get_articles(self, context): 63 | try: 64 | edit_mode = context["request"].toolbar.edit_mode_active 65 | except (AttributeError, KeyError): 66 | edit_mode = False 67 | 68 | if edit_mode: 69 | articles = Article.objects.drafts() 70 | else: 71 | articles = Article.objects.public().published() 72 | 73 | for attribute in self.attributes.all(): 74 | articles = articles.filter(attributes=attribute) 75 | 76 | return articles 77 | 78 | 79 | class ArticlesPlugin(ArticlesPluginBase): 80 | trees = models.ManyToManyField( 81 | Page, 82 | verbose_name=_("trees"), 83 | related_name="+", 84 | blank=True, 85 | limit_choices_to={ 86 | "publisher_is_draft": False, 87 | "application_urls": "CMSArticlesApp", 88 | "node__site_id": settings.SITE_ID, 89 | }, 90 | ) 91 | categories = models.ManyToManyField(Category, verbose_name=_("categories"), related_name="+", blank=True) 92 | 93 | def __str__(self): 94 | return _("last {} articles").format(self.number) 95 | 96 | def get_articles(self, context): 97 | articles = super().get_articles(context) 98 | 99 | if self.trees.count(): 100 | articles = articles.filter(tree__in=self.trees.all()) 101 | 102 | if self.categories.count(): 103 | articles = articles.filter(categories__in=self.categories.all()) 104 | 105 | return articles 106 | 107 | def copy_relations(self, oldinstance): 108 | self.trees.set(oldinstance.trees.all()) 109 | self.categories.set(oldinstance.categories.all()) 110 | super().copy_relations(oldinstance) 111 | 112 | 113 | class ArticlesCategoryPlugin(ArticlesPluginBase): 114 | subcategories = models.BooleanField( 115 | _("include sub-categories"), 116 | default=False, 117 | help_text=_("Check, if you want to include articles from sub-categories of this category."), 118 | ) 119 | 120 | def __str__(self): 121 | return _("last {} articles in this category").format(self.number) 122 | 123 | def get_articles(self, context): 124 | # no page - no category 125 | if self.placeholder.page is None: 126 | return [] 127 | 128 | page = self.placeholder.page.get_draft_object() 129 | try: 130 | category = page.cms_articles_category 131 | except Category.DoesNotExist: 132 | category = Category.objects.create(page=page) 133 | 134 | articles = super().get_articles(context) 135 | 136 | if self.subcategories: 137 | if not self.placeholder.page.is_home: 138 | articles = articles.filter(categories__page__node__path__startswith=page.node.path) 139 | # if self.placeholder.page.is_home, take all 140 | else: 141 | articles = articles.filter(categories=category) 142 | 143 | return articles 144 | -------------------------------------------------------------------------------- /cms_articles/models/query.py: -------------------------------------------------------------------------------- 1 | from cms.models.query import PageQuerySet 2 | 3 | 4 | class ArticleQuerySet(PageQuerySet): 5 | def on_site(self, site=None): 6 | from cms.utils import get_current_site 7 | 8 | if site is None: 9 | site = get_current_site() 10 | return self.filter(tree__node__site=site) 11 | -------------------------------------------------------------------------------- /cms_articles/models/title.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from datetime import timedelta 5 | 6 | from cms.constants import PUBLISHER_STATE_DIRTY 7 | from django.db import models 8 | from django.utils import timezone 9 | from django.utils.translation import gettext_lazy as _ 10 | from djangocms_text_ckeditor.fields import HTMLField 11 | from filer.fields.image import FilerImageField 12 | 13 | from .article import Article 14 | from .managers import TitleManager 15 | 16 | 17 | class Title(models.Model): 18 | # These are the fields whose values are compared when saving 19 | # a Title object to know if it has changed. 20 | editable_fields = [ 21 | "title", 22 | "slug", 23 | "page_title", 24 | "menu_title", 25 | "meta_description", 26 | ] 27 | 28 | article = models.ForeignKey(Article, verbose_name=_("article"), related_name="title_set", on_delete=models.CASCADE) 29 | language = models.CharField(_("language"), max_length=15, db_index=True) 30 | title = models.CharField(_("title"), max_length=255) 31 | description = HTMLField( 32 | _("description"), blank=True, default="", help_text=_("The text displayed in an articles overview.") 33 | ) 34 | page_title = models.CharField( 35 | _("page title"), max_length=255, blank=True, null=True, help_text=_("overwrite the title (html title tag)") 36 | ) 37 | menu_title = models.CharField( 38 | _("menu title"), 39 | max_length=255, 40 | blank=True, 41 | null=True, 42 | help_text=_("overwrite the title in the articles overview"), 43 | ) 44 | meta_description = models.TextField( 45 | _("meta description"), 46 | max_length=155, 47 | blank=True, 48 | null=True, 49 | help_text=_("The text displayed in search engines."), 50 | ) 51 | slug = models.SlugField(_("slug"), max_length=255, db_index=True, unique=False) 52 | creation_date = models.DateTimeField(_("creation date"), editable=False, default=timezone.now) 53 | image = FilerImageField(verbose_name=_("image"), related_name="+", on_delete=models.PROTECT, blank=True, null=True) 54 | 55 | # Publisher fields 56 | published = models.BooleanField(_("is published"), blank=True, default=False) 57 | publisher_is_draft = models.BooleanField(default=True, editable=False, db_index=True) 58 | # This is misnamed - the one-to-one relation is populated on both ends 59 | publisher_public = models.OneToOneField( 60 | "self", related_name="publisher_draft", on_delete=models.CASCADE, null=True, editable=False 61 | ) 62 | publisher_state = models.SmallIntegerField(default=0, editable=False, db_index=True) 63 | 64 | objects = TitleManager() 65 | 66 | class Meta: 67 | unique_together = (("language", "article"),) 68 | app_label = "cms_articles" 69 | 70 | def __str__(self): 71 | return "%s (%s, %s)" % (self.title, self.slug, self.language) 72 | 73 | def is_dirty(self): 74 | return self.publisher_state == PUBLISHER_STATE_DIRTY 75 | 76 | def save_base(self, *args, **kwargs): 77 | """Overridden save_base. If an instance is draft, and was changed, mark 78 | it as dirty. 79 | 80 | Dirty flag is used for changed nodes identification when publish method 81 | takes place. After current changes are published, state is set back to 82 | PUBLISHER_STATE_DEFAULT (in publish method). 83 | """ 84 | keep_state = getattr(self, "_publisher_keep_state", None) 85 | 86 | # Published articles should always have a publication date 87 | # if the article is published we set the publish date if not set yet. 88 | if self.article.publication_date is None and self.published: 89 | self.article.publication_date = timezone.now() - timedelta(seconds=5) 90 | 91 | if self.publisher_is_draft and not keep_state and self.is_new_dirty(): 92 | self.publisher_state = PUBLISHER_STATE_DIRTY 93 | 94 | if keep_state: 95 | delattr(self, "_publisher_keep_state") 96 | return super().save_base(*args, **kwargs) 97 | 98 | def is_new_dirty(self): 99 | if not self.pk: 100 | return True 101 | 102 | try: 103 | old_title = Title.objects.get(pk=self.pk) 104 | except Title.DoesNotExist: 105 | return True 106 | 107 | for field in self.editable_fields: 108 | old_val = getattr(old_title, field) 109 | new_val = getattr(self, field) 110 | if not old_val == new_val: 111 | return True 112 | return False 113 | -------------------------------------------------------------------------------- /cms_articles/search_indexes.py: -------------------------------------------------------------------------------- 1 | from aldryn_search.helpers import get_plugin_index_data 2 | from aldryn_search.signals import add_to_index, remove_from_index 3 | from aldryn_search.utils import clean_join, get_index_base 4 | from cms.models import CMSPlugin 5 | from cms.signals import post_publish, post_unpublish 6 | from django.db.models import Q 7 | from django.dispatch.dispatcher import receiver 8 | from django.utils import timezone 9 | 10 | from .conf import settings 11 | from .models import Title 12 | 13 | 14 | class TitleIndex(get_index_base()): 15 | index_title = True 16 | 17 | object_actions = ("publish", "unpublish") 18 | haystack_use_for_indexing = settings.CMS_ARTICLES_USE_HAYSTACK 19 | 20 | def prepare_pub_date(self, obj): 21 | return obj.article.publication_date 22 | 23 | def prepare_login_required(self, obj): 24 | return obj.article.login_required 25 | 26 | def prepare_site_id(self, obj): 27 | return obj.article.tree.node.site_id 28 | 29 | def get_language(self, obj): 30 | return obj.language 31 | 32 | def get_url(self, obj): 33 | return obj.article.get_absolute_url() 34 | 35 | def get_title(self, obj): 36 | return obj.title 37 | 38 | def get_description(self, obj): 39 | return obj.meta_description or None 40 | 41 | def get_plugin_queryset(self, language): 42 | queryset = CMSPlugin.objects.filter(language=language) 43 | return queryset 44 | 45 | def get_article_placeholders(self, article): 46 | """ 47 | In the project settings set up the variable 48 | 49 | CMS_ARTICLES_PLACEHOLDERS_SEARCH_LIST = { 50 | 'include': [ 'slot1', 'slot2', etc. ], 51 | 'exclude': [ 'slot3', 'slot4', etc. ], 52 | } 53 | 54 | or leave it empty 55 | 56 | CMS_ARTICLES_PLACEHOLDERS_SEARCH_LIST = {} 57 | """ 58 | placeholders_search_list = getattr(settings, "CMS_ARTICLES_PLACEHOLDERS_SEARCH_LIST", {}) 59 | 60 | included = placeholders_search_list.get("include", []) 61 | excluded = placeholders_search_list.get("exclude", []) 62 | diff = set(included) - set(excluded) 63 | if diff: 64 | return article.placeholders.filter(slot__in=diff) 65 | elif excluded: 66 | return article.placeholders.exclude(slot__in=excluded) 67 | else: 68 | return article.placeholders.all() 69 | 70 | def get_search_data(self, obj, language, request): 71 | current_article = obj.article 72 | placeholders = self.get_article_placeholders(current_article) 73 | plugins = self.get_plugin_queryset(language).filter(placeholder__in=placeholders) 74 | text_bits = [] 75 | 76 | for base_plugin in plugins: 77 | plugin_text_content = self.get_plugin_search_text(base_plugin, request) 78 | text_bits.append(plugin_text_content) 79 | 80 | article_meta_description = current_article.get_meta_description(fallback=False, language=language) 81 | 82 | if article_meta_description: 83 | text_bits.append(article_meta_description) 84 | 85 | article_meta_keywords = getattr(current_article, "get_meta_keywords", None) 86 | 87 | if callable(article_meta_keywords): 88 | text_bits.append(article_meta_keywords()) 89 | 90 | return clean_join(" ", text_bits) 91 | 92 | def get_plugin_search_text(self, base_plugin, request): 93 | plugin_content_bits = get_plugin_index_data(base_plugin, request) 94 | return clean_join(" ", plugin_content_bits) 95 | 96 | def get_model(self): 97 | return Title 98 | 99 | def get_index_queryset(self, language): 100 | queryset = ( 101 | Title.objects.public() 102 | .filter( 103 | Q(article__publication_date__lt=timezone.now()) | Q(article__publication_date__isnull=True), 104 | Q(article__publication_end_date__gte=timezone.now()) | Q(article__publication_end_date__isnull=True), 105 | language=language, 106 | ) 107 | .select_related("article") 108 | .distinct() 109 | ) 110 | return queryset 111 | 112 | def should_update(self, instance, **kwargs): 113 | # We use the action flag to prevent 114 | # updating the cms article on save. 115 | return kwargs.get("object_action") in self.object_actions 116 | 117 | 118 | @receiver(post_publish, dispatch_uid="publish_cms_article") 119 | def publish_cms_article(sender, instance, language, **kwargs): 120 | title = instance.publisher_public.get_title_obj(language) 121 | print("##################### publish_cms_article", title) 122 | add_to_index.send(sender=Title, instance=title, object_action="publish") 123 | 124 | 125 | @receiver(post_unpublish, dispatch_uid="unpublish_cms_article") 126 | def unpublish_cms_article(sender, instance, language, **kwargs): 127 | title = instance.publisher_public.get_title_obj(language) 128 | print("##################### unpublish_cms_article", title) 129 | remove_from_index.send(sender=Title, instance=title, object_action="unpublish") 130 | -------------------------------------------------------------------------------- /cms_articles/signals/__init__.py: -------------------------------------------------------------------------------- 1 | from cms.signals import post_placeholder_operation 2 | from django.db.models import signals 3 | 4 | from ..admin.article import ArticleAdmin 5 | from ..models import Article, Title 6 | from .article import post_save_article, pre_delete_article, pre_save_article 7 | from .plugins import post_reorder_plugins, pre_delete_plugins, pre_save_plugins 8 | from .title import pre_delete_title, pre_save_title 9 | 10 | # Signals we listen to 11 | 12 | post_placeholder_operation.connect( 13 | post_reorder_plugins, sender=ArticleAdmin, dispatch_uid="cms_articles_post_reorder_plugins" 14 | ) 15 | 16 | signals.pre_save.connect(pre_save_plugins, dispatch_uid="cms_articles_pre_save_plugin") 17 | signals.pre_delete.connect(pre_delete_plugins, dispatch_uid="cms_articles_pre_delete_plugin") 18 | 19 | signals.pre_save.connect(pre_save_article, sender=Article, dispatch_uid="cms_articles_pre_save_article") 20 | signals.post_save.connect(post_save_article, sender=Article, dispatch_uid="cms_articles_post_save_article") 21 | signals.pre_delete.connect(pre_delete_article, sender=Article, dispatch_uid="cms_articles_pre_delete_article") 22 | 23 | 24 | signals.pre_save.connect(pre_save_title, sender=Title, dispatch_uid="cms_articles_pre_save_article") 25 | signals.pre_delete.connect(pre_delete_title, sender=Title, dispatch_uid="cms_articles_pre_delete_article") 26 | -------------------------------------------------------------------------------- /cms_articles/signals/article.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.template import TemplateDoesNotExist 4 | 5 | from ..models import Article 6 | 7 | 8 | def pre_save_article(instance, **kwargs): 9 | instance.old_article = None 10 | try: 11 | instance.old_article = Article.objects.get(pk=instance.pk) 12 | except Article.DoesNotExist: 13 | pass 14 | 15 | 16 | def post_save_article(instance, raw, **kwargs): 17 | if not raw: 18 | try: 19 | instance.rescan_placeholders() 20 | except TemplateDoesNotExist as e: 21 | warnings.warn("Exception occurred: %s template does not exists" % e) 22 | 23 | 24 | def pre_delete_article(instance, **kwargs): 25 | for placeholder in instance.get_placeholders(): 26 | for plugin in placeholder.cmsplugin_set.all().order_by("-depth"): 27 | plugin._no_reorder = True 28 | plugin.delete(no_mp=True) 29 | placeholder.delete() 30 | -------------------------------------------------------------------------------- /cms_articles/signals/plugins.py: -------------------------------------------------------------------------------- 1 | from cms.constants import PUBLISHER_STATE_DIRTY 2 | from cms.models import CMSPlugin, Placeholder 3 | 4 | 5 | def _set_dirty_placeholder(placeholder, language): 6 | for article in placeholder.cms_articles.all(): 7 | article.title_set.filter(language=language).update(publisher_state=PUBLISHER_STATE_DIRTY) 8 | 9 | 10 | def _set_dirty_plugin(plugin): 11 | if plugin.placeholder_id: 12 | try: 13 | placeholder = plugin.placeholder 14 | except Placeholder.DoesNotExist: 15 | placeholder = None 16 | else: 17 | placeholder = plugin.placeholder 18 | 19 | if placeholder: 20 | _set_dirty_placeholder(placeholder, plugin.language) 21 | 22 | 23 | def post_reorder_plugins(**kwargs): 24 | for prefix in ("source", "target"): 25 | placeholder = kwargs.get(prefix + "_placeholder") 26 | language = kwargs.get(prefix + "_language") 27 | if placeholder and language: 28 | _set_dirty_placeholder(placeholder, language) 29 | 30 | placeholder = kwargs.get("placeholder") 31 | if placeholder: 32 | for arg_name in ("plugin", "old_plugin", "new_plugin"): 33 | plugin = kwargs.get("plugin") 34 | if plugin: 35 | _set_dirty_placeholder(placeholder, plugin.language) 36 | break 37 | 38 | 39 | def pre_save_plugins(**kwargs): 40 | plugin = kwargs["instance"] 41 | 42 | if not isinstance(plugin, CMSPlugin) or hasattr(plugin, "_no_reorder"): 43 | return 44 | 45 | _set_dirty_plugin(plugin) 46 | 47 | if not plugin.pk: 48 | return 49 | 50 | try: 51 | old_plugin = ( 52 | CMSPlugin.objects.select_related("placeholder") 53 | .only("language", "placeholder") 54 | .exclude(placeholder=plugin.placeholder_id) 55 | .get(pk=plugin.pk) 56 | ) 57 | except CMSPlugin.DoesNotExist: 58 | pass 59 | else: 60 | _set_dirty_plugin(old_plugin) 61 | 62 | 63 | def pre_delete_plugins(**kwargs): 64 | plugin = kwargs["instance"] 65 | 66 | if not isinstance(plugin, CMSPlugin) or hasattr(plugin, "_no_reorder"): 67 | return 68 | 69 | _set_dirty_plugin(plugin) 70 | -------------------------------------------------------------------------------- /cms_articles/signals/title.py: -------------------------------------------------------------------------------- 1 | def pre_save_title(instance, **kwargs): 2 | """Update article.languages""" 3 | if instance.article.languages: 4 | languages = instance.article.languages.split(",") 5 | else: 6 | languages = [] 7 | if instance.language not in languages: 8 | languages.append(instance.language) 9 | instance.article.languages = ",".join(languages) 10 | instance.article._publisher_keep_state = True 11 | instance.article.save(no_signals=True) 12 | 13 | 14 | def pre_delete_title(instance, **kwargs): 15 | """Update article.languages""" 16 | if instance.article.languages: 17 | languages = instance.article.languages.split(",") 18 | else: 19 | languages = [] 20 | if instance.language in languages: 21 | languages.remove(instance.language) 22 | instance.article.languages = ",".join(languages) 23 | instance.article._publisher_keep_state = True 24 | instance.article.save(no_signals=True) 25 | -------------------------------------------------------------------------------- /cms_articles/static/cms_articles/css/changelist.css: -------------------------------------------------------------------------------- 1 | th.column-view, 2 | th[class*="column-lang_"], 3 | th[class="column-preview_link"] { 4 | width: 1%; 5 | text-align: center; 6 | } 7 | td.field-view, 8 | td[class*="field-lang_"] { 9 | text-align: center; 10 | } 11 | .sr-only { 12 | position: absolute; 13 | width: 1px; 14 | height: 1px; 15 | padding: 0; 16 | margin: -1px; 17 | overflow: hidden; 18 | clip: rect(0,0,0,0); 19 | border: 0; 20 | } 21 | .cms-pagetree-dropdown-menu-open { 22 | display: block; 23 | } 24 | .cms-hover-tooltip {z-index: auto;} 25 | 26 | /* fix conflicts with djangocms-admin.css */ 27 | .change-list table td .cms-pagetree-dropdown-menu a:not(.cke_button) { 28 | line-height: 1.5; 29 | padding: 10px 15px; 30 | } 31 | .change-list table td .cms-tree-item-preview a:not(.cke_button) { 32 | font-size: 18px !important; 33 | line-height: 18px; 34 | } 35 | -------------------------------------------------------------------------------- /cms_articles/static/cms_articles/js/changelist.js: -------------------------------------------------------------------------------- 1 | (function($) { $(function() { 2 | var dropdownSelector = '.js-cms-pagetree-dropdown'; 3 | var triggerSelector = '.js-cms-pagetree-dropdown-trigger'; 4 | var menuSelector = '.js-cms-pagetree-dropdown-menu'; 5 | var openCls = 'cms-pagetree-dropdown-menu-open'; 6 | 7 | // attach event to the trigger 8 | $(triggerSelector).click(function (e) { 9 | e.preventDefault(); 10 | e.stopImmediatePropagation(); 11 | 12 | _toggleDropdown(this); 13 | }); 14 | 15 | // stop propagation on the element 16 | $(menuSelector).click(function (e) { 17 | e.stopImmediatePropagation(); 18 | }); 19 | 20 | $(menuSelector + ' a').click(function () { 21 | closeAllDropdowns(); 22 | }); 23 | 24 | $('body').click(function () { 25 | closeAllDropdowns(); 26 | }); 27 | 28 | function _toggleDropdown(trigger) { 29 | var dropdowns = $(dropdownSelector); 30 | var dropdown = $(trigger).closest(dropdownSelector); 31 | 32 | // cancel if opened tooltip is triggered again 33 | if (dropdown.hasClass(openCls)) { 34 | dropdowns.removeClass(openCls); 35 | return false; 36 | } 37 | 38 | // otherwise show the dropdown 39 | dropdowns.removeClass(openCls); 40 | dropdown.addClass(openCls); 41 | } 42 | 43 | function closeAllDropdowns() { 44 | $(dropdownSelector).removeClass(openCls); 45 | } 46 | 47 | $('.js-cms-tree-lang-trigger').click(function (e) { 48 | e.preventDefault(); 49 | $.ajax({ 50 | method: 'post', 51 | url: $(this).attr('href'), 52 | data: { 53 | csrfmiddlewaretoken: getCookie('csrftoken'), 54 | } 55 | }).done(function () { 56 | window.location.reload(); 57 | }); 58 | }); 59 | 60 | function getCookie(name) { 61 | var cookieValue = null; 62 | if (document.cookie && document.cookie !== '') { 63 | var cookies = document.cookie.split(';'); 64 | for (var i = 0; i < cookies.length; i++) { 65 | var cookie = $.trim(cookies[i]); 66 | // Does this cookie string begin with the name we want? 67 | if (cookie.substring(0, name.length + 1) === (name + '=')) { 68 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 69 | break; 70 | } 71 | } 72 | } 73 | return cookieValue; 74 | } 75 | 76 | })})(django.jQuery); 77 | -------------------------------------------------------------------------------- /cms_articles/templates/admin/cms_articles/article/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/cms/page/change_form.html" %} 2 | {% load i18n admin_urls admin_modify cms_admin cms_static %} 3 | 4 | {% block content_title %} 5 |

{% block title %}{% if add %}{% trans "Add an article" %}{% else %}{% trans "Change an article" %}{% endif %}{% endblock %}

6 | {% endblock %} 7 | 8 | {% block content %} 9 | 10 | {% with add=True %} 11 | {{ block.super }} 12 | {% endwith %} 13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /cms_articles/templates/admin/cms_articles/article/change_list_lang.html: -------------------------------------------------------------------------------- 1 | {% load i18n cms_admin %} 2 |
3 |
4 | {% if has_publish_permission %} 5 | 9 | {# INFO: renders #} 10 | {% tree_publish_row article lang %} 11 | 12 | 13 |
14 | 50 |
51 | {% else %} 52 | 53 | {% tree_publish_row article lang %} 54 | 55 | {% endif %} 56 |
57 |
58 | -------------------------------------------------------------------------------- /cms_articles/templates/admin/cms_articles/article/change_list_preview.html: -------------------------------------------------------------------------------- 1 | {% load i18n cms_admin %} 2 | 11 | -------------------------------------------------------------------------------- /cms_articles/templates/admin/cms_articles/article_changelist.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load cms_static static %} 3 | 4 | {% block extrahead %} 5 | {{ block.super }} 6 | {# INFO: we need to add styles here instead of "extrastyle" to avoid conflicts with adminstyle #} 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% endblock extrahead %} 14 | -------------------------------------------------------------------------------- /cms_articles/templates/cms_articles/article/default.html: -------------------------------------------------------------------------------- 1 | {% if article %}{% include 'cms_articles/article_preview.html' %}{% endif %} 2 | -------------------------------------------------------------------------------- /cms_articles/templates/cms_articles/article_preview.html: -------------------------------------------------------------------------------- 1 | {% load cms_articles thumbnail %} 2 |
3 |

4 | {{ article.get_menu_title }} 5 | {{ article.order_date }} 6 |

7 | {% with image=article.get_image %} 8 | {% if image %} 9 | 10 | {{ image.name }} 11 | 12 | {% endif %} 13 | {% endwith %} 14 | {{ article.get_description | safe }} 15 |
16 | -------------------------------------------------------------------------------- /cms_articles/templates/cms_articles/articles/default.html: -------------------------------------------------------------------------------- 1 | {% load i18n cms_articles %} 2 | 3 | {% for article in articles %} 4 | {% include 'cms_articles/article_preview.html' %} 5 | {% endfor %} 6 | 7 | 22 | 23 |

{% trans 'Archive' %}

24 | 25 |
    26 | {% for year_archive in archive.years %} 27 |
  • 28 | {{ year_archive.year }} 29 | {% if year_archive.active %} 30 | 37 | {% endif %} 38 |
  • 39 | {% endfor %} 40 |
41 | 42 | -------------------------------------------------------------------------------- /cms_articles/templates/cms_articles/base.html: -------------------------------------------------------------------------------- 1 | {% load cms_tags menu_tags sekizai_tags %} 2 | 3 | 4 | 5 | {% block page_title %}This is my new project home page{% endblock page_title %} 6 | 7 | 8 | 30 | {% render_block "css" %} 31 | 32 | 33 | {% cms_toolbar %} 34 |
35 | 38 | {% block content %}{% endblock content %} 39 |
40 | {% render_block "js" %} 41 | 42 | 43 | -------------------------------------------------------------------------------- /cms_articles/templates/cms_articles/default.html: -------------------------------------------------------------------------------- 1 | {% extends "cms_articles/base.html" %} 2 | {% load cms_articles thumbnail %} 3 | 4 | {% block page_title %}{% article_attribute "page_title" %}{% endblock %} 5 | {% block meta_description %}{% article_attribute "meta_description" %}{% endblock %} 6 | {% block breadcrumb %}{% show_article_breadcrumb %}{% endblock %} 7 | 8 | {% block content %} 9 | 10 |

{% block title %}{% article_attribute "title" %}{% endblock %}

11 | 12 | {% article_attribute "image" as image %} 13 | {% if image %} 14 | 15 | {{ image.name }} 16 | 17 | {% endif %} 18 | 19 | {% article_placeholder "content" %} 20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /cms_articles/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbsoftware/django-cms-articles/f22eba829172e0a4f4d3c332d60ca4f16277ff10/cms_articles/templatetags/__init__.py -------------------------------------------------------------------------------- /cms_articles/templatetags/cms_articles.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from classytags.arguments import Argument, MultiValueArgument 4 | from classytags.core import Options, Tag 5 | from classytags.helpers import AsTag 6 | from cms.exceptions import PlaceholderNotFound 7 | from cms.templatetags.cms_tags import DeclaredPlaceholder, PlaceholderOptions 8 | from cms.toolbar.utils import get_toolbar_from_request 9 | from cms.utils import get_language_from_request, get_site_id 10 | from cms.utils.moderator import use_draft 11 | from django import template 12 | from django.contrib.sites.models import Site 13 | from django.core.mail import mail_managers 14 | from django.middleware.common import BrokenLinkEmailsMiddleware 15 | from django.utils.encoding import force_str 16 | from django.utils.html import escape 17 | from django.utils.translation import gettext_lazy as _ 18 | from menus.base import NavigationNode 19 | from menus.templatetags.menu_tags import ShowBreadcrumb 20 | 21 | from ..conf import settings 22 | from ..models import Article 23 | from ..utils.placeholder import validate_placeholder_name 24 | 25 | register = template.Library() 26 | 27 | 28 | def _get_article_by_untyped_arg(article_lookup, request, site_id): 29 | """ 30 | The `article_lookup` argument can be of any of the following types: 31 | - Integer: interpreted as `pk` of the desired article 32 | - `dict`: a dictionary containing keyword arguments to find the desired article 33 | (for instance: `{'pk': 1}`) 34 | - `Article`: you can also pass an Article object directly, in which case there will be no database lookup. 35 | - `None`: the current article will be used 36 | """ 37 | if article_lookup is None: 38 | return request.current_article 39 | if isinstance(article_lookup, Article): 40 | if hasattr(request, "current_article") and request.current_article.pk == article_lookup.pk: 41 | return request.current_article 42 | return article_lookup 43 | 44 | if isinstance(article_lookup, int): 45 | article_lookup = {"pk": article_lookup} 46 | elif not isinstance(article_lookup, dict): 47 | raise TypeError("The article_lookup argument can be either a Dictionary, Integer, or Article.") 48 | article_lookup.update({"site": site_id}) 49 | try: 50 | article = Article.objects.all().get(**article_lookup) 51 | if request and use_draft(request): 52 | if article.publisher_is_draft: 53 | return article 54 | else: 55 | return article.publisher_draft 56 | else: 57 | if article.publisher_is_draft: 58 | return article.publisher_public 59 | else: 60 | return article 61 | except Article.DoesNotExist: 62 | site = Site.objects.get_current() 63 | subject = _("Article not found on %(domain)s") % {"domain": site.domain} 64 | body = _( 65 | "A template tag couldn't find the article with lookup arguments `%(article_lookup)s\n`. " 66 | "The URL of the request was: http://%(host)s%(path)s" 67 | ) % {"article_lookup": repr(article_lookup), "host": site.domain, "path": request.path_info} 68 | if settings.DEBUG: 69 | raise Article.DoesNotExist(body) 70 | else: 71 | mw = settings.MIDDLEWARE 72 | if getattr(settings, "SEND_BROKEN_LINK_EMAILS", False): 73 | mail_managers(subject, body, fail_silently=True) 74 | elif "django.middleware.common.BrokenLinkEmailsMiddleware" in mw: 75 | middle = BrokenLinkEmailsMiddleware() 76 | domain = request.get_host() 77 | path = request.get_full_path() 78 | referer = force_str(request.headers.get("Referer", ""), errors="replace") 79 | if not middle.is_ignorable_request(request, path, domain, referer): 80 | mail_managers(subject, body, fail_silently=True) 81 | return None 82 | 83 | 84 | class ArticlePlaceholder(Tag): 85 | """ 86 | This template node is used to output article content and 87 | is also used in the admin to dynamically generate input fields. 88 | 89 | eg: {% article_placeholder "placeholder_name" %} 90 | 91 | {% article_placeholder "footer" or %} 92 | About us 93 | {% endarticle_placeholder %} 94 | 95 | Keyword arguments: 96 | name -- the name of the placeholder 97 | or -- optional argument which if given will make the template tag a block 98 | tag whose content is shown if the placeholder is empty 99 | """ 100 | 101 | name = "article_placeholder" 102 | options = PlaceholderOptions( 103 | Argument("name", resolve=False), 104 | MultiValueArgument("extra_bits", required=False, resolve=False), 105 | blocks=[ 106 | ("endarticle_placeholder", "nodelist"), 107 | ], 108 | ) 109 | 110 | def render_tag(self, context, name, extra_bits, nodelist=None): 111 | request = context.get("request") 112 | 113 | if not request: 114 | return "" 115 | 116 | validate_placeholder_name(name) 117 | 118 | toolbar = get_toolbar_from_request(request) 119 | renderer = toolbar.get_content_renderer() 120 | inherit = False 121 | 122 | try: 123 | content = renderer.render_page_placeholder( 124 | slot=name, 125 | context=context, 126 | inherit=inherit, 127 | page=request.current_article, 128 | nodelist=nodelist, 129 | ) 130 | except PlaceholderNotFound: 131 | content = "" 132 | 133 | if not content and nodelist: 134 | return nodelist.render(context) 135 | return content 136 | 137 | def get_declaration(self): 138 | slot = self.kwargs["name"].var.value.strip('"').strip("'") 139 | 140 | return DeclaredPlaceholder(slot=slot, inherit=False) 141 | 142 | 143 | register.tag("article_placeholder", ArticlePlaceholder) 144 | 145 | 146 | class ArticleAttribute(AsTag): 147 | """ 148 | This template node is used to output an attribute from a article such 149 | as its title or slug. 150 | 151 | Synopsis 152 | {% article_attribute "field-name" %} 153 | {% article_attribute "field-name" as varname %} 154 | {% article_attribute "field-name" article_lookup %} 155 | {% article_attribute "field-name" article_lookup as varname %} 156 | 157 | Example 158 | {# Output current article's page_title attribute: #} 159 | {% article_attribute "page_title" %} 160 | {# Output slug attribute of the article with pk 10: #} 161 | {% article_attribute "slug" 10 %} 162 | {# Assign page_title attribute to a variable: #} 163 | {% article_attribute "page_title" as title %} 164 | 165 | Keyword arguments: 166 | field-name -- the name of the field to output. Use one of: 167 | - title 168 | - description 169 | - page_title 170 | - slug 171 | - meta_description 172 | - changed_date 173 | - changed_by 174 | - image 175 | 176 | article_lookup -- lookup argument for Article, if omitted field-name of current article is returned. 177 | See _get_article_by_untyped_arg() for detailed information on the allowed types and their interpretation 178 | for the article_lookup argument. 179 | 180 | varname -- context variable name. Output will be added to template context as this variable. 181 | This argument is required to follow the 'as' keyword. 182 | """ 183 | 184 | name = "article_attribute" 185 | options = Options( 186 | Argument("name", resolve=False), 187 | Argument("article_lookup", required=False, default=None), 188 | "as", 189 | Argument("varname", required=False, resolve=False), 190 | ) 191 | 192 | valid_attributes = [ 193 | "title", 194 | "slug", 195 | "description", 196 | "page_title", 197 | "menu_title", 198 | "meta_description", 199 | "changed_date", 200 | "changed_by", 201 | "image", 202 | ] 203 | 204 | def get_value(self, context, name, article_lookup): 205 | if "request" not in context: 206 | return "" 207 | name = name.lower() 208 | request = context["request"] 209 | lang = get_language_from_request(request) 210 | article = _get_article_by_untyped_arg(article_lookup, request, get_site_id(None)) 211 | if article and name in self.valid_attributes: 212 | func = getattr(article, "get_%s" % name) 213 | ret_val = func(language=lang, fallback=True) 214 | if name not in ("changed_date", "image"): 215 | ret_val = escape(ret_val) 216 | return ret_val 217 | return "" 218 | 219 | 220 | register.tag("article_attribute", ArticleAttribute) 221 | 222 | 223 | class ShowArticleBreadcrumb(ShowBreadcrumb): 224 | name = "show_article_breadcrumb" 225 | 226 | def get_context(self, context, start_level, template, only_visible): 227 | context = super().get_context(context, start_level, template, only_visible) 228 | try: 229 | current_article = context["request"].current_article 230 | except (AttributeError, KeyError): 231 | pass 232 | else: 233 | context["ancestors"].append( 234 | NavigationNode( 235 | title=current_article.get_menu_title(), 236 | url=current_article.get_absolute_url(), 237 | id=current_article.pk, 238 | visible=True, 239 | ) 240 | ) 241 | return context 242 | 243 | 244 | register.tag("show_article_breadcrumb", ShowArticleBreadcrumb) 245 | 246 | 247 | @register.simple_tag(takes_context=True) 248 | def url_page(context, page): 249 | get = context["request"].GET.copy() 250 | get[settings.CMS_ARTICLES_PAGE_FIELD] = page 251 | return "{}?{}".format(context["request"].path, get.urlencode()) 252 | -------------------------------------------------------------------------------- /cms_articles/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbsoftware/django-cms-articles/f22eba829172e0a4f4d3c332d60ca4f16277ff10/cms_articles/tests/__init__.py -------------------------------------------------------------------------------- /cms_articles/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | from cms.api import create_page 2 | 3 | 4 | class ArticlesTestFixture: 5 | """Sets up generic setUp and tearDown methods for tests.""" 6 | 7 | def setUp(self): 8 | self.language = "en" 9 | self.home = create_page( 10 | title="home", 11 | template="page.html", 12 | language=self.language, 13 | ) 14 | self.home.publish(self.language) 15 | self.page = create_page( 16 | title="content", 17 | template="page.html", 18 | language=self.language, 19 | ) 20 | self.page.publish(self.language) 21 | self.placeholder = self.page.placeholders.get(slot="content") 22 | self.superuser = self.get_superuser() 23 | self.request_url = self.page.get_absolute_url(self.language) + "?toolbar_off=true" 24 | 25 | return super().setUp() 26 | 27 | def tearDown(self): 28 | self.page.delete() 29 | self.home.delete() 30 | self.superuser.delete() 31 | 32 | return super().tearDown() 33 | -------------------------------------------------------------------------------- /cms_articles/tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cms_articles/tests project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-cms-articles-tests" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sites", 38 | "djangocms_text_ckeditor", 39 | "easy_thumbnails", 40 | "filer", 41 | "cms", 42 | "cms_articles", 43 | "menus", 44 | "sekizai", 45 | "treebeard", 46 | ] 47 | 48 | MIDDLEWARE = [] 49 | 50 | ROOT_URLCONF = "cms_articles.tests.urls" 51 | 52 | TEMPLATES = [ 53 | { 54 | "BACKEND": "django.template.backends.django.DjangoTemplates", 55 | "DIRS": ["cms_articles/tests/templates"], 56 | "APP_DIRS": True, 57 | "OPTIONS": { 58 | "context_processors": [ 59 | "django.template.context_processors.debug", 60 | "django.template.context_processors.request", 61 | "django.contrib.auth.context_processors.auth", 62 | "django.contrib.messages.context_processors.messages", 63 | ], 64 | }, 65 | }, 66 | ] 67 | 68 | WSGI_APPLICATION = "cms_articles.tests.wsgi.application" 69 | 70 | 71 | # Database 72 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 73 | 74 | DATABASES = { 75 | "default": { 76 | "ENGINE": "django.db.backends.sqlite3", 77 | "NAME": BASE_DIR / "db.sqlite3", 78 | } 79 | } 80 | 81 | 82 | # Password validation 83 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 84 | 85 | AUTH_PASSWORD_VALIDATORS = [ 86 | { 87 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 88 | }, 89 | { 90 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 97 | }, 98 | ] 99 | 100 | 101 | # Internationalization 102 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 103 | 104 | LANGUAGE_CODE = "en" 105 | 106 | TIME_ZONE = "UTC" 107 | 108 | USE_I18N = True 109 | 110 | USE_TZ = True 111 | 112 | 113 | # Static files (CSS, JavaScript, Images) 114 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 115 | 116 | STATIC_URL = "static/" 117 | 118 | # Default primary key field type 119 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 120 | 121 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 122 | 123 | CMS_LANGUAGES = {1: [{"code": "en", "name": "English"}]} 124 | CMS_TEMPLATES = [ 125 | ("default.html", "Default"), 126 | ] 127 | 128 | SITE_ID = 1 129 | -------------------------------------------------------------------------------- /cms_articles/tests/templates/default.html: -------------------------------------------------------------------------------- 1 | {% extends "cms_articles/base.html" %} 2 | {% load cms_tags thumbnail %} 3 | 4 | {% block page_title %}{% page_attribute "page_title" %}{% endblock %} 5 | {% block meta_description %}{% page_attribute "meta_description" %}{% endblock %} 6 | {% block breadcrumb %}{% endblock %} 7 | 8 | {% block content %} 9 | 10 |

{% block title %}{% page_attribute "title" %}{% endblock %}

11 | 12 | {% placeholder "content" %} 13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /cms_articles/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cms.api import create_page 3 | 4 | from cms_articles.api import create_article 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_create_article() -> None: 9 | page = create_page( 10 | title="News", 11 | template="default.html", 12 | language="en", 13 | apphook="CMSArticlesApp", 14 | apphook_namespace="news", 15 | published=True, 16 | ) 17 | create_article( 18 | tree=page, 19 | title="Article", 20 | template="cms_articles/default.html", 21 | language="en", 22 | published=True, 23 | ) 24 | -------------------------------------------------------------------------------- /cms_articles/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | from .conf import settings 5 | 6 | if settings.APPEND_SLASH: 7 | regexp = r"^(?P{})/$".format(settings.CMS_ARTICLES_SLUG_REGEXP) 8 | else: 9 | regexp = r"^(?P{})$".format(settings.CMS_ARTICLES_SLUG_REGEXP) 10 | 11 | urlpatterns = [ 12 | url(regexp, views.article, name="article"), 13 | ] 14 | -------------------------------------------------------------------------------- /cms_articles/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | 3 | 4 | def is_valid_article_slug(article, language, slug): 5 | """Validates given slug depending on settings.""" 6 | from ..models import Title 7 | 8 | qs = Title.objects.filter(slug=slug, language=language) 9 | 10 | if article.pk: 11 | qs = qs.exclude(Q(language=language) & Q(article=article)) 12 | qs = qs.exclude(article__publisher_public=article) 13 | 14 | if qs.count(): 15 | return False 16 | 17 | return True 18 | -------------------------------------------------------------------------------- /cms_articles/utils/article.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from cms.utils.page import _page_is_published 5 | 6 | 7 | def get_article_from_slug(tree, slug, preview=False, draft=False): 8 | """ 9 | Resolves a slug to a single article object. 10 | Returns None if article does not exist 11 | """ 12 | from ..models import Title 13 | 14 | titles = Title.objects.select_related("article").filter(article__tree=tree) 15 | published_only = not draft and not preview 16 | 17 | if draft: 18 | titles = titles.filter(publisher_is_draft=True) 19 | elif preview: 20 | titles = titles.filter(publisher_is_draft=False) 21 | else: 22 | titles = titles.filter(published=True, publisher_is_draft=False) 23 | titles = titles.filter(slug=slug) 24 | 25 | for title in titles.iterator(): 26 | if published_only and not _page_is_published(title.article): 27 | continue 28 | 29 | title.article.title_cache = {title.language: title} 30 | return title.article 31 | return 32 | -------------------------------------------------------------------------------- /cms_articles/utils/placeholder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import warnings 3 | 4 | from cms.exceptions import DuplicatePlaceholderWarning 5 | from cms.utils.placeholder import _get_nodelist, _scan_placeholders, validate_placeholder_name 6 | from django.template.loader import get_template 7 | 8 | 9 | def get_placeholders(template): 10 | from ..templatetags.cms_articles import ArticlePlaceholder 11 | 12 | compiled_template = get_template(template) 13 | 14 | placeholders = [] 15 | nodes = _scan_placeholders(_get_nodelist(compiled_template), ArticlePlaceholder) 16 | clean_placeholders = [] 17 | 18 | for node in nodes: 19 | placeholder = node.get_declaration() 20 | slot = placeholder.slot 21 | 22 | if slot in clean_placeholders: 23 | warnings.warn( 24 | 'Duplicate {{% placeholder "{0}" %}} ' "in template {1}.".format(slot, template), 25 | DuplicatePlaceholderWarning, 26 | ) 27 | else: 28 | validate_placeholder_name(slot) 29 | placeholders.append(placeholder) 30 | clean_placeholders.append(slot) 31 | return placeholders 32 | -------------------------------------------------------------------------------- /cms_articles/views.py: -------------------------------------------------------------------------------- 1 | from cms.exceptions import LanguageError 2 | from cms.page_rendering import _handle_no_page, render_object_structure 3 | from cms.utils.conf import get_cms_setting 4 | from cms.utils.i18n import ( 5 | get_default_language_for_site, 6 | get_fallback_languages, 7 | get_language_list, 8 | get_public_languages, 9 | get_redirect_on_fallback, 10 | ) 11 | from cms.utils.moderator import use_draft 12 | from cms.views import details as page 13 | from django.conf import settings 14 | from django.contrib.auth.views import redirect_to_login 15 | from django.http import HttpResponseRedirect 16 | from django.utils.http import urlquote 17 | from django.utils.translation import get_language_from_request 18 | 19 | from .article_rendering import render_article 20 | from .utils.article import get_article_from_slug 21 | 22 | 23 | def article(request, slug): 24 | """ 25 | The main view of the Django-CMS Articles! Takes a request and a slug, 26 | renders the article. 27 | """ 28 | # Get current CMS Page as article tree 29 | tree = request.current_page.get_public_object() 30 | 31 | # Check whether it really is a tree. 32 | # It could also be one of its sub-pages. 33 | if tree.application_urls != "CMSArticlesApp": 34 | # In such case show regular CMS Page 35 | return page(request, slug) 36 | 37 | # Get an Article object from the request 38 | draft = use_draft(request) and request.user.has_perm("cms_articles.change_article") 39 | preview = "preview" in request.GET and request.user.has_perm("cms_articles.change_article") 40 | 41 | site = tree.node.site 42 | article = get_article_from_slug(tree, slug, preview, draft) 43 | 44 | if not article: 45 | # raise 404 46 | _handle_no_page(request) 47 | 48 | request.current_article = article 49 | 50 | if hasattr(request, "user") and request.user.is_staff: 51 | user_languages = get_language_list(site_id=site.pk) 52 | else: 53 | user_languages = get_public_languages(site_id=site.pk) 54 | 55 | request_language = get_language_from_request(request, check_path=True) 56 | 57 | # get_published_languages will return all languages in draft mode 58 | # and published only in live mode. 59 | # These languages are then filtered out by the user allowed languages 60 | available_languages = [ 61 | language for language in user_languages if language in list(article.get_published_languages()) 62 | ] 63 | 64 | own_urls = [ 65 | request.build_absolute_uri(request.path), 66 | "/%s" % request.path, 67 | request.path, 68 | ] 69 | 70 | try: 71 | redirect_on_fallback = get_redirect_on_fallback(request_language, site_id=site.pk) 72 | except LanguageError: 73 | redirect_on_fallback = False 74 | 75 | if request_language not in user_languages: 76 | # Language is not allowed 77 | # Use the default site language 78 | default_language = get_default_language_for_site(site.pk) 79 | fallbacks = get_fallback_languages(default_language, site_id=site.pk) 80 | fallbacks = [default_language] + fallbacks 81 | else: 82 | fallbacks = get_fallback_languages(request_language, site_id=site.pk) 83 | 84 | # Only fallback to languages the user is allowed to see 85 | fallback_languages = [ 86 | language for language in fallbacks if language != request_language and language in available_languages 87 | ] 88 | language_is_unavailable = request_language not in available_languages 89 | 90 | if language_is_unavailable and not fallback_languages: 91 | # There is no page with the requested language 92 | # and there's no configured fallbacks 93 | return _handle_no_page(request) 94 | elif language_is_unavailable and redirect_on_fallback: 95 | # There is no page with the requested language and 96 | # the user has explicitly requested to redirect on fallbacks, 97 | # so redirect to the first configured / available fallback language 98 | fallback = fallback_languages[0] 99 | redirect_url = article.get_absolute_url(fallback, fallback=False) 100 | else: 101 | redirect_url = False 102 | 103 | if redirect_url: 104 | if request.user.is_staff and hasattr(request, "toolbar") and request.toolbar.edit_mode_active: 105 | request.toolbar.redirect_url = redirect_url 106 | elif redirect_url not in own_urls: 107 | # prevent redirect to self 108 | return HttpResponseRedirect(redirect_url) 109 | 110 | # permission checks 111 | if article.login_required and not request.user.is_authenticated(): 112 | return redirect_to_login(urlquote(request.get_full_path()), settings.LOGIN_URL) 113 | 114 | if hasattr(request, "toolbar"): 115 | request.toolbar.obj = article 116 | 117 | structure_requested = get_cms_setting("CMS_TOOLBAR_URL__BUILD") in request.GET 118 | 119 | if article.has_change_permission(request) and structure_requested: 120 | return render_object_structure(request, article) 121 | return render_article(request, article, current_language=request_language, slug=slug) 122 | -------------------------------------------------------------------------------- /messages: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pushd cms_articles 4 | django-admin makemessages -l cs 5 | vim locale/cs/LC_MESSAGES/django.po 6 | django-admin compilemessages 7 | popd 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | 4 | [tool.isort] 5 | combine_as_imports = true 6 | ensure_newline_before_comments = true 7 | force_grid_wrap = 0 8 | include_trailing_comma = true 9 | known_first_party = "cms_articles" 10 | line_length = 120 11 | multi_line_output = 3 12 | profile = "black" 13 | skip_glob = "*migrations*" 14 | use_parentheses = true 15 | 16 | [tool.poetry] 17 | name = "django-cms-articles" 18 | version = "2.1.5" 19 | description = "django CMS application for managing articles" 20 | authors = ["Jakub Dorňák "] 21 | license = "BSD-3-Clause" 22 | readme = "README.md" 23 | packages = [{include = "cms_articles"}] 24 | repository = "https://github.com/misli/django-cms-articles" 25 | classifiers = [ 26 | "Development Status :: 5 - Production/Stable", 27 | "Environment :: Web Environment", 28 | "Framework :: Django", 29 | "Framework :: Django :: 2", 30 | "Framework :: Django :: 3", 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: BSD License", 33 | "Natural Language :: Czech", 34 | "Natural Language :: English", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3", 38 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 39 | "Topic :: Software Development", 40 | "Topic :: Software Development :: Libraries :: Application Frameworks", 41 | ] 42 | 43 | [tool.poetry.dependencies] 44 | python = "^3.8" 45 | Django = "<4" 46 | django-cms = "<3.12" 47 | django-filer = "*" 48 | djangocms-text-ckeditor = "*" 49 | python-dateutil = "*" 50 | 51 | [tool.poetry.dev-dependencies] 52 | black = "*" 53 | flake8 = "*" 54 | isort = "*" 55 | pytest-django = "*" 56 | mypy = "*" 57 | 58 | [build-system] 59 | build-backend = "poetry.core.masonry.api" 60 | requires = ["poetry-core"] 61 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = cms_articles.tests.settings 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W503, E203, E501 3 | max-line-length = 120 4 | 5 | [tox] 6 | basepython = python3 7 | envlist = 8 | black 9 | flake8 10 | isort 11 | py3{6,7}-dj2-cms3{7,8} 12 | py3{8,9,10}-dj3-cms3{8,9,10} 13 | isolated_build = True 14 | skipsdist = True 15 | skip_missing_interpreters=True 16 | 17 | [testenv] 18 | commands = pytest 19 | deps= 20 | pytest-django 21 | dj2: Django>=2,<3 22 | dj3: Django>=3,<4 23 | dj4: Django>=4,<5 24 | cms37: django-cms>=3.7,<3.8 25 | cms38: django-cms>=3.8,<3.9 26 | cms39: django-cms>=3.9,<3.10 27 | cms310: django-cms>=3.10,<3.11 28 | cms311: django-cms>=3.11,<3.12 29 | setenv = DJANGO_SETTINGS_MODULE=tests.settings 30 | 31 | [testenv:black] 32 | deps = black 33 | commands = black --check cms_articles 34 | skip_install = true 35 | 36 | [testenv:flake8] 37 | deps = flake8 38 | commands = flake8 cms_articles 39 | skip_install = true 40 | 41 | [testenv:isort] 42 | deps = isort 43 | commands = isort --check-only cms_articles 44 | skip_install = true 45 | --------------------------------------------------------------------------------