diff --git a/setup/website_blog_scheduled_publication/odoo/addons/website_blog_scheduled_publication b/setup/website_blog_scheduled_publication/odoo/addons/website_blog_scheduled_publication new file mode 120000 index 0000000000..acdef5d7e6 --- /dev/null +++ b/setup/website_blog_scheduled_publication/odoo/addons/website_blog_scheduled_publication @@ -0,0 +1 @@ +../../../../website_blog_scheduled_publication \ No newline at end of file diff --git a/setup/website_blog_scheduled_publication/setup.py b/setup/website_blog_scheduled_publication/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/website_blog_scheduled_publication/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/website_blog_scheduled_publication/README.rst b/website_blog_scheduled_publication/README.rst new file mode 100644 index 0000000000..6ed0b3abe3 --- /dev/null +++ b/website_blog_scheduled_publication/README.rst @@ -0,0 +1,184 @@ +========================== +Blog Scheduled Publication +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:132329775a846e3e9b5c3c742d03461131d87458a9e8d56b104671807883642c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwebsite-lightgray.png?logo=github + :target: https://github.com/OCA/website/tree/16.0/website_blog_scheduled_publication + :alt: OCA/website +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/website-16-0/website-16-0-website_blog_scheduled_publication + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/website&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows you to schedule blog posts for automatic publication at a specific date and time. + +Features +-------- + +* **Scheduled Publication Date**: Add a datetime field to schedule when posts should be published +* **Quick Publish Button**: One-click immediate publication from the form view +* **Schedule Wizard**: User-friendly wizard to set the publication date +* **Visual Feedback**: Information banner displays scheduled date on the form +* **Smart Publication Logic**: Automatically prevents immediate publication if a future date is set +* **Automatic Cron Job**: Runs every hour to publish scheduled posts +* **Notification System**: Sends notifications to blog followers when posts are published +* **Search Filters**: Filter and group posts by publication or scheduled dates + +Technical Details +----------------- + +* Extends ``blog.post`` model with scheduling fields and methods +* Includes a transient model ``blog.post.schedule.date`` for the scheduling wizard +* Inherits and enhances form and search views from ``website_blog`` +* The cron job uses ``_publish_scheduled_posts()`` method to process scheduled posts +* Handles edge cases like setting past dates, clearing scheduled dates, and multiple record writes + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To use this module, you need to: + +#. Install the module from Apps menu +#. Go to **Website > Content > Blog Posts** +#. Create or edit a blog post +#. In the "Publishing Options" section, you will find the "Scheduled Publication Date" field +#. Set a future date and time when you want the post to be published +#. Save the post + +The post will be automatically published when the scheduled date and time arrives. + +Note: The cron job that publishes scheduled posts runs every hour. If you need more frequent checks, you can adjust the cron job interval in **Settings > Technical > Automation > Scheduled Actions**. + +Usage +===== + +Scheduling a Blog Post +====================== + +Using the Form View +------------------- + +1. Go to **Website > Content > Blog Posts** +2. Open a blog post form +3. You will see two buttons in the button box: + + * **Publish**: Click to publish the post immediately + * **Schedule**: Click to open the scheduling wizard + +4. When clicking "Schedule", a dialog will appear asking for the publication date +5. Select the desired date and time, then click "Schedule" +6. The post will display an information banner showing the scheduled publication date + +Using the Schedule Type Field +----------------------------- + +In the blog post form, you can also set the "Scheduled Publication Date" field directly: + +* Set the date field to a future date and time +* The post will automatically be scheduled for that time +* The post will remain unpublished until the scheduled time + +Viewing Scheduled Posts +----------------------- + +Use the search filters to find scheduled posts: + +* **Published**: Filter to show only published posts +* **Not Published**: Filter to show unpublished posts (including scheduled ones) + +You can also group posts by: + +* **Publication Date**: Group by the actual publication date +* **Scheduled Publication Date**: Group by the scheduled publication date + +Automatic Publication +--------------------- + +A cron job runs every hour to publish posts that have reached their scheduled time: + +* Posts are automatically published when the scheduled date/time arrives +* Notifications are sent to blog followers when posts are published +* The scheduled date is cleared after publication + +Immediate Publication +--------------------- + +If you need to publish a scheduled post immediately: + +1. Open the blog post form +2. Click the "Publish" button +3. Confirm the action in the dialog +4. The post will be published immediately and the scheduled date will be cleared + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Escodoo + +Contributors +~~~~~~~~~~~~ + +* `Escodoo `_: + + * Marcel Savegnago + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-marcelsavegnago| image:: https://github.com/marcelsavegnago.png?size=40px + :target: https://github.com/marcelsavegnago + :alt: marcelsavegnago + +Current `maintainer `__: + +|maintainer-marcelsavegnago| + +This module is part of the `OCA/website `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_blog_scheduled_publication/__init__.py b/website_blog_scheduled_publication/__init__.py new file mode 100644 index 0000000000..9b4296142f --- /dev/null +++ b/website_blog_scheduled_publication/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/website_blog_scheduled_publication/__manifest__.py b/website_blog_scheduled_publication/__manifest__.py new file mode 100644 index 0000000000..7ebd63d5db --- /dev/null +++ b/website_blog_scheduled_publication/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 - TODAY, Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Blog Scheduled Publication", + "summary": "Schedule blog posts publication date", + "version": "16.0.1.0.0", + "author": "Escodoo, Odoo Community Association (OCA)", + "maintainers": ["marcelsavegnago"], + "license": "AGPL-3", + "category": "Website", + "website": "https://github.com/OCA/website", + "depends": [ + "website_blog", + ], + "data": [ + "security/ir.model.access.csv", + "data/ir_cron.xml", + "views/blog_post_views.xml", + "wizard/blog_post_schedule_date_views.xml", + ], + "installable": True, + "application": False, +} diff --git a/website_blog_scheduled_publication/data/ir_cron.xml b/website_blog_scheduled_publication/data/ir_cron.xml new file mode 100644 index 0000000000..72de0df500 --- /dev/null +++ b/website_blog_scheduled_publication/data/ir_cron.xml @@ -0,0 +1,15 @@ + + + + Publish Scheduled Blog Posts + + code + model._publish_scheduled_posts() + + 1 + hours + -1 + + + + diff --git a/website_blog_scheduled_publication/models/__init__.py b/website_blog_scheduled_publication/models/__init__.py new file mode 100644 index 0000000000..d914446ea2 --- /dev/null +++ b/website_blog_scheduled_publication/models/__init__.py @@ -0,0 +1 @@ +from . import blog_post diff --git a/website_blog_scheduled_publication/models/blog_post.py b/website_blog_scheduled_publication/models/blog_post.py new file mode 100644 index 0000000000..0030619bb6 --- /dev/null +++ b/website_blog_scheduled_publication/models/blog_post.py @@ -0,0 +1,224 @@ +# Copyright 2026 - TODAY, Marcel Savegnago +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +def _to_datetime(value): + """Convert string to datetime if needed. + + Note: When accessing a Datetime field from a record (e.g., + post.scheduled_publication_date), Odoo automatically returns a Python + datetime object. However, when working with vals dictionaries (from forms, + wizards, or API calls), the value may come as a string and needs to be + converted to datetime for proper comparison operations. + """ + if not value: + return False + if isinstance(value, str): + return fields.Datetime.from_string(value) + return value + + +class BlogPost(models.Model): + _inherit = "blog.post" + + scheduled_publication_date = fields.Datetime( + help="If set, the blog post will be automatically published on " + "this date and time. Leave empty to publish immediately.", + ) + is_scheduled = fields.Boolean( + string="Schedule", + default=False, + help="If checked, the blog post will be scheduled for publication.", + ) + + @api.model + def _publish_scheduled_posts(self): + """Cron job method to publish scheduled blog posts.""" + now = fields.Datetime.now() + scheduled_posts = self.search( + [ + ("scheduled_publication_date", "<=", now), + ("scheduled_publication_date", "!=", False), + ("website_published", "=", False), + ("active", "=", True), + ] + ) + if scheduled_posts: + scheduled_posts.write({"is_published": True}) + scheduled_posts.write( + {"scheduled_publication_date": False, "is_scheduled": False} + ) + return True + + def action_publish_now(self): + """Publish the blog post immediately.""" + self.write( + { + "is_scheduled": False, + "scheduled_publication_date": False, + "is_published": True, + } + ) + return True + + def action_schedule(self): + """Open schedule dialog to set publication date.""" + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "website_blog_scheduled_publication.blog_post_schedule_date_action" + ) + action["context"] = dict( + self.env.context, + default_blog_post_id=self.id, + default_schedule_date=self.scheduled_publication_date or False, + dialog_size="medium", + ) + return action + + def _check_for_publication(self, vals): + """Override to handle publication notifications properly. + + Skip notifications for posts that are scheduled for the future. + Use sudo() to ensure notifications can be sent regardless of user + permissions, as this is a system action that should always succeed. + """ + if not vals.get("is_published"): + return False + + now = fields.Datetime.now() + posts_to_notify = self.filtered( + lambda p: not self._has_future_scheduled_date(p, vals, now) + ) + if not posts_to_notify: + return False + return super(BlogPost, posts_to_notify.sudo())._check_for_publication(vals) + + def _has_future_scheduled_date(self, post, vals, now): + """Check if post has a future scheduled date. + + Note: post.scheduled_publication_date is already a datetime object + (Odoo converts it automatically), but vals.get() may return a string + that needs conversion via _to_datetime(). + """ + if post.scheduled_publication_date and post.scheduled_publication_date > now: + return True + scheduled_date = _to_datetime(vals.get("scheduled_publication_date")) + return scheduled_date and scheduled_date > now + + @api.model_create_multi + def create(self, vals_list): + """Override create to handle scheduled publication during creation.""" + now = fields.Datetime.now() + processed_vals_list = [] + for vals in vals_list: + processed_vals = self._process_create_vals(vals, now) + processed_vals_list.append(processed_vals) + return super().create(processed_vals_list) + + def _process_create_vals(self, vals, now): + """Process vals for create to handle scheduled dates.""" + processed_vals = vals.copy() + if "scheduled_publication_date" not in processed_vals: + return processed_vals + + scheduled_date = _to_datetime(processed_vals.get("scheduled_publication_date")) + if scheduled_date and scheduled_date > now: + if processed_vals.get("website_published"): + processed_vals["website_published"] = False + elif scheduled_date and scheduled_date <= now: + if processed_vals.get("website_published"): + processed_vals["scheduled_publication_date"] = False + return processed_vals + + def write(self, vals): + """Override write to handle scheduled publication logic.""" + relevant_fields = { + "scheduled_publication_date", + "website_published", + "is_scheduled", + } + if not relevant_fields.intersection(vals.keys()): + return super().write(vals) + + now = fields.Datetime.now() + for record in self: + record_vals = self._process_write_vals(record, vals, now) + super(BlogPost, record.with_context(**self.env.context)).write(record_vals) + return True + + def _process_write_vals(self, record, vals, now): + """Process vals for write to handle scheduled publication logic.""" + record_vals = vals.copy() + scheduled_date = self._get_effective_scheduled_date(record, record_vals) + + self._handle_is_scheduled_change(record_vals, scheduled_date, now) + self._handle_scheduled_date_change(record, record_vals, now) + self._handle_publication_request(record, record_vals, scheduled_date, now) + + return record_vals + + def _get_effective_scheduled_date(self, record, vals): + """Get the scheduled date that will be effective after write. + + Note: Uses _to_datetime() for both cases to ensure consistent + datetime objects, even though record.scheduled_publication_date + is already a datetime (defensive programming). + """ + if "scheduled_publication_date" in vals: + return _to_datetime(vals.get("scheduled_publication_date")) + return _to_datetime(record.scheduled_publication_date) + + def _handle_is_scheduled_change(self, vals, scheduled_date, now): + """Handle is_scheduled field changes.""" + if "is_scheduled" not in vals: + return + + if not vals["is_scheduled"]: + vals["scheduled_publication_date"] = False + else: + if "scheduled_publication_date" not in vals: + if not scheduled_date or scheduled_date <= now: + vals["scheduled_publication_date"] = now + relativedelta(days=1) + + def _handle_scheduled_date_change(self, record, vals, now): + """Handle scheduled_publication_date field changes.""" + if "scheduled_publication_date" not in vals: + return + + new_date_raw = vals.get("scheduled_publication_date") + new_date = _to_datetime(new_date_raw) + + if new_date and new_date > now: + self._set_future_scheduled_date(record, vals, new_date_raw) + elif new_date and new_date <= now: + vals["scheduled_publication_date"] = False + vals.setdefault("is_scheduled", False) + elif not new_date: + vals.setdefault("is_scheduled", False) + + def _set_future_scheduled_date(self, record, vals, new_date_raw): + """Set a future scheduled date and unpublish if needed.""" + will_be_published = vals.get("website_published", record.website_published) + if will_be_published: + vals["website_published"] = False + vals.setdefault("is_scheduled", True) + vals["scheduled_publication_date"] = new_date_raw + + def _handle_publication_request(self, record, vals, scheduled_date, now): + """Handle publication request when website_published is being set.""" + if "website_published" not in vals or not vals.get("website_published"): + return + + if scheduled_date and scheduled_date > now: + vals["website_published"] = False + vals.setdefault("scheduled_publication_date", scheduled_date) + vals.setdefault("is_scheduled", True) + elif scheduled_date and scheduled_date <= now: + vals["scheduled_publication_date"] = False + vals.setdefault("is_scheduled", False) + else: + vals.setdefault("is_scheduled", False) diff --git a/website_blog_scheduled_publication/readme/CONFIGURE.rst b/website_blog_scheduled_publication/readme/CONFIGURE.rst new file mode 100644 index 0000000000..2738a731f8 --- /dev/null +++ b/website_blog_scheduled_publication/readme/CONFIGURE.rst @@ -0,0 +1,12 @@ +To use this module, you need to: + +#. Install the module from Apps menu +#. Go to **Website > Content > Blog Posts** +#. Create or edit a blog post +#. In the "Publishing Options" section, you will find the "Scheduled Publication Date" field +#. Set a future date and time when you want the post to be published +#. Save the post + +The post will be automatically published when the scheduled date and time arrives. + +Note: The cron job that publishes scheduled posts runs every hour. If you need more frequent checks, you can adjust the cron job interval in **Settings > Technical > Automation > Scheduled Actions**. diff --git a/website_blog_scheduled_publication/readme/CONTRIBUTORS.rst b/website_blog_scheduled_publication/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..ae453a60bd --- /dev/null +++ b/website_blog_scheduled_publication/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Escodoo `_: + + * Marcel Savegnago diff --git a/website_blog_scheduled_publication/readme/DESCRIPTION.rst b/website_blog_scheduled_publication/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..7952b36837 --- /dev/null +++ b/website_blog_scheduled_publication/readme/DESCRIPTION.rst @@ -0,0 +1,22 @@ +This module allows you to schedule blog posts for automatic publication at a specific date and time. + +Features +-------- + +* **Scheduled Publication Date**: Add a datetime field to schedule when posts should be published +* **Quick Publish Button**: One-click immediate publication from the form view +* **Schedule Wizard**: User-friendly wizard to set the publication date +* **Visual Feedback**: Information banner displays scheduled date on the form +* **Smart Publication Logic**: Automatically prevents immediate publication if a future date is set +* **Automatic Cron Job**: Runs every hour to publish scheduled posts +* **Notification System**: Sends notifications to blog followers when posts are published +* **Search Filters**: Filter and group posts by publication or scheduled dates + +Technical Details +----------------- + +* Extends ``blog.post`` model with scheduling fields and methods +* Includes a transient model ``blog.post.schedule.date`` for the scheduling wizard +* Inherits and enhances form and search views from ``website_blog`` +* The cron job uses ``_publish_scheduled_posts()`` method to process scheduled posts +* Handles edge cases like setting past dates, clearing scheduled dates, and multiple record writes diff --git a/website_blog_scheduled_publication/readme/USAGE.rst b/website_blog_scheduled_publication/readme/USAGE.rst new file mode 100644 index 0000000000..1626d65f8a --- /dev/null +++ b/website_blog_scheduled_publication/readme/USAGE.rst @@ -0,0 +1,57 @@ +Scheduling a Blog Post +====================== + +Using the Form View +------------------- + +1. Go to **Website > Content > Blog Posts** +2. Open a blog post form +3. You will see two buttons in the button box: + + * **Publish**: Click to publish the post immediately + * **Schedule**: Click to open the scheduling wizard + +4. When clicking "Schedule", a dialog will appear asking for the publication date +5. Select the desired date and time, then click "Schedule" +6. The post will display an information banner showing the scheduled publication date + +Using the Schedule Type Field +----------------------------- + +In the blog post form, you can also set the "Scheduled Publication Date" field directly: + +* Set the date field to a future date and time +* The post will automatically be scheduled for that time +* The post will remain unpublished until the scheduled time + +Viewing Scheduled Posts +----------------------- + +Use the search filters to find scheduled posts: + +* **Published**: Filter to show only published posts +* **Not Published**: Filter to show unpublished posts (including scheduled ones) + +You can also group posts by: + +* **Publication Date**: Group by the actual publication date +* **Scheduled Publication Date**: Group by the scheduled publication date + +Automatic Publication +--------------------- + +A cron job runs every hour to publish posts that have reached their scheduled time: + +* Posts are automatically published when the scheduled date/time arrives +* Notifications are sent to blog followers when posts are published +* The scheduled date is cleared after publication + +Immediate Publication +--------------------- + +If you need to publish a scheduled post immediately: + +1. Open the blog post form +2. Click the "Publish" button +3. Confirm the action in the dialog +4. The post will be published immediately and the scheduled date will be cleared diff --git a/website_blog_scheduled_publication/security/ir.model.access.csv b/website_blog_scheduled_publication/security/ir.model.access.csv new file mode 100644 index 0000000000..d19ab8c265 --- /dev/null +++ b/website_blog_scheduled_publication/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_blog_post_scheduled_publication,blog.post.scheduled.publication,website_blog.model_blog_post,,1,1,1,1 +access_blog_post_schedule_date,blog.post.schedule.date,website_blog_scheduled_publication.model_blog_post_schedule_date,,1,1,1,1 diff --git a/website_blog_scheduled_publication/static/description/index.html b/website_blog_scheduled_publication/static/description/index.html new file mode 100644 index 0000000000..9d223d382c --- /dev/null +++ b/website_blog_scheduled_publication/static/description/index.html @@ -0,0 +1,523 @@ + + + + + +Blog Scheduled Publication + + + +
+

Blog Scheduled Publication

+ + +

Beta License: AGPL-3 OCA/website Translate me on Weblate Try me on Runboat

+

This module allows you to schedule blog posts for automatic publication at a specific date and time.

+
+

Features

+
    +
  • Scheduled Publication Date: Add a datetime field to schedule when posts should be published
  • +
  • Quick Publish Button: One-click immediate publication from the form view
  • +
  • Schedule Wizard: User-friendly wizard to set the publication date
  • +
  • Visual Feedback: Information banner displays scheduled date on the form
  • +
  • Smart Publication Logic: Automatically prevents immediate publication if a future date is set
  • +
  • Automatic Cron Job: Runs every hour to publish scheduled posts
  • +
  • Notification System: Sends notifications to blog followers when posts are published
  • +
  • Search Filters: Filter and group posts by publication or scheduled dates
  • +
+
+
+

Technical Details

+
    +
  • Extends blog.post model with scheduling fields and methods
  • +
  • Includes a transient model blog.post.schedule.date for the scheduling wizard
  • +
  • Inherits and enhances form and search views from website_blog
  • +
  • The cron job uses _publish_scheduled_posts() method to process scheduled posts
  • +
  • Handles edge cases like setting past dates, clearing scheduled dates, and multiple record writes
  • +
+

Table of contents

+ +
+

Configuration

+

To use this module, you need to:

+
    +
  1. Install the module from Apps menu
  2. +
  3. Go to Website > Content > Blog Posts
  4. +
  5. Create or edit a blog post
  6. +
  7. In the “Publishing Options” section, you will find the “Scheduled Publication Date” field
  8. +
  9. Set a future date and time when you want the post to be published
  10. +
  11. Save the post
  12. +
+

The post will be automatically published when the scheduled date and time arrives.

+

Note: The cron job that publishes scheduled posts runs every hour. If you need more frequent checks, you can adjust the cron job interval in Settings > Technical > Automation > Scheduled Actions.

+
+
+

Usage

+
+
+

Scheduling a Blog Post

+
+
+
+

Using the Form View

+
    +
  1. Go to Website > Content > Blog Posts
  2. +
  3. Open a blog post form
  4. +
  5. You will see two buttons in the button box:
      +
    • Publish: Click to publish the post immediately
    • +
    • Schedule: Click to open the scheduling wizard
    • +
    +
  6. +
  7. When clicking “Schedule”, a dialog will appear asking for the publication date
  8. +
  9. Select the desired date and time, then click “Schedule”
  10. +
  11. The post will display an information banner showing the scheduled publication date
  12. +
+
+
+

Using the Schedule Type Field

+

In the blog post form, you can also set the “Scheduled Publication Date” field directly:

+
    +
  • Set the date field to a future date and time
  • +
  • The post will automatically be scheduled for that time
  • +
  • The post will remain unpublished until the scheduled time
  • +
+
+
+

Viewing Scheduled Posts

+

Use the search filters to find scheduled posts:

+
    +
  • Published: Filter to show only published posts
  • +
  • Not Published: Filter to show unpublished posts (including scheduled ones)
  • +
+

You can also group posts by:

+
    +
  • Publication Date: Group by the actual publication date
  • +
  • Scheduled Publication Date: Group by the scheduled publication date
  • +
+
+
+

Automatic Publication

+

A cron job runs every hour to publish posts that have reached their scheduled time:

+
    +
  • Posts are automatically published when the scheduled date/time arrives
  • +
  • Notifications are sent to blog followers when posts are published
  • +
  • The scheduled date is cleared after publication
  • +
+
+
+

Immediate Publication

+

If you need to publish a scheduled post immediately:

+
    +
  1. Open the blog post form
  2. +
  3. Click the “Publish” button
  4. +
  5. Confirm the action in the dialog
  6. +
  7. The post will be published immediately and the scheduled date will be cleared
  8. +
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Escodoo
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

marcelsavegnago

+

This module is part of the OCA/website project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/website_blog_scheduled_publication/tests/__init__.py b/website_blog_scheduled_publication/tests/__init__.py new file mode 100644 index 0000000000..96bd759d35 --- /dev/null +++ b/website_blog_scheduled_publication/tests/__init__.py @@ -0,0 +1 @@ +from . import test_blog_post_scheduled_publication diff --git a/website_blog_scheduled_publication/tests/test_blog_post_scheduled_publication.py b/website_blog_scheduled_publication/tests/test_blog_post_scheduled_publication.py new file mode 100644 index 0000000000..6e2be52461 --- /dev/null +++ b/website_blog_scheduled_publication/tests/test_blog_post_scheduled_publication.py @@ -0,0 +1,884 @@ +# Copyright 2026 - TODAY, Marcel Savegnago +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestBlogPostScheduledPublication(TransactionCase): + def setUp(self): + super().setUp() + # Create a blog + self.blog = self.env["blog.blog"].create({"name": "Test Blog"}) + # Create a user with blog manager rights + group_blog_manager = self.env.ref("website.group_website_designer") + self.user_blogmanager = ( + self.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Blog Manager", + "login": "blog_manager", + "email": "blog.manager@example.com", + "groups_id": [(6, 0, [group_blog_manager.id])], + } + ) + ) + + def test_scheduled_publication_future_date(self): + """Test that a post with future scheduled date is not published immediately.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "website_published": True, # Try to publish immediately + } + ) + ) + # Post should not be published because of future scheduled date + self.assertFalse( + post.website_published, + "Post with future scheduled date should not be published immediately", + ) + self.assertEqual( + post.scheduled_publication_date, + future_date, + "Scheduled date should be preserved", + ) + + def test_scheduled_publication_past_date(self): + """Test that a post with past scheduled date is published immediately.""" + past_date = fields.Datetime.now() - timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Past Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": True, + } + ) + ) + # Post should be published immediately and scheduled date cleared + self.assertTrue( + post.website_published, + "Post with past scheduled date should be published immediately", + ) + self.assertFalse( + post.scheduled_publication_date, + "Past scheduled date should be cleared after publication", + ) + + def test_scheduled_publication_no_date(self): + """Test that a post without scheduled date is published immediately.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Immediate Post", + "blog_id": self.blog.id, + "website_published": True, + } + ) + ) + # Post should be published immediately + self.assertTrue( + post.website_published, + "Post without scheduled date should be published immediately", + ) + self.assertFalse( + post.scheduled_publication_date, + "Post without scheduled date should have no scheduled date", + ) + + def test_scheduled_publication_set_future_date_on_published_post(self): + """Test that setting a future date on a published post unpublishes it.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Published Post", + "blog_id": self.blog.id, + "website_published": True, + } + ) + ) + self.assertTrue(post.website_published, "Post should be published") + + # Set a future scheduled date + future_date = fields.Datetime.now() + timedelta(days=1) + post.with_context(mail_create_nolog=True).write( + {"scheduled_publication_date": future_date} + ) + + # Post should be unpublished + self.assertFalse( + post.website_published, + "Post should be unpublished when future scheduled date is set", + ) + self.assertEqual( + post.scheduled_publication_date, + future_date, + "Scheduled date should be set", + ) + + def test_scheduled_publication_clear_future_date(self): + """Test that clearing a future scheduled date allows immediate publication.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + } + ) + ) + self.assertFalse(post.website_published, "Post should not be published") + + # Clear scheduled date and publish + post.with_context(mail_create_nolog=True).write( + {"scheduled_publication_date": False, "website_published": True} + ) + + # Post should be published + self.assertTrue( + post.website_published, + "Post should be published when scheduled date is cleared", + ) + self.assertFalse( + post.scheduled_publication_date, + "Scheduled date should be cleared", + ) + + def test_cron_publish_scheduled_posts(self): + """Test that cron job publishes scheduled posts when date arrives.""" + # Count initial messages on blog + initial_message_count = len(self.blog.message_ids) + + # Create posts with past scheduled dates + past_date = fields.Datetime.now() - timedelta(hours=1) + scheduled_post1 = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Scheduled Post 1", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": False, + } + ) + ) + scheduled_post2 = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Scheduled Post 2", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": False, + } + ) + ) + + # Create a post with future scheduled date (should not be published) + future_date = fields.Datetime.now() + timedelta(days=1) + future_post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Future Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "website_published": False, + } + ) + ) + + # Run the cron job + self.env["blog.post"]._publish_scheduled_posts() + + # Past scheduled posts should be published + self.assertTrue( + scheduled_post1.website_published, + "Past scheduled post should be published by cron", + ) + self.assertFalse( + scheduled_post1.scheduled_publication_date, + "Scheduled date should be cleared after publication", + ) + + self.assertTrue( + scheduled_post2.website_published, + "Past scheduled post should be published by cron", + ) + self.assertFalse( + scheduled_post2.scheduled_publication_date, + "Scheduled date should be cleared after publication", + ) + + # Future post should not be published + self.assertFalse( + future_post.website_published, + "Future scheduled post should not be published by cron", + ) + self.assertEqual( + future_post.scheduled_publication_date, + future_date, + "Future scheduled date should be preserved", + ) + + # Verify that notifications were sent (messages should be created on blog) + # Each published post should create a notification message + final_message_count = len(self.blog.message_ids) + self.assertGreater( + final_message_count, + initial_message_count, + "Notifications should be sent when posts are published by cron", + ) + + def test_cron_skip_inactive_posts(self): + """Test that cron job skips inactive posts.""" + past_date = fields.Datetime.now() - timedelta(hours=1) + inactive_post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Inactive Post", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": False, + "active": False, + } + ) + ) + + # Run the cron job + self.env["blog.post"]._publish_scheduled_posts() + + # Inactive post should not be published + self.assertFalse( + inactive_post.website_published, + "Inactive post should not be published by cron", + ) + + def test_cron_skip_already_published_posts(self): + """Test that cron job skips already published posts.""" + past_date = fields.Datetime.now() - timedelta(hours=1) + published_post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Already Published Post", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": True, + } + ) + ) + + # Run the cron job + self.env["blog.post"]._publish_scheduled_posts() + + # Post should remain published + self.assertTrue( + published_post.website_published, + "Already published post should remain published", + ) + + def test_scheduled_publication_string_to_datetime_conversion(self): + """Test that string dates are properly converted to datetime.""" + future_date = fields.Datetime.now() + timedelta(days=1) + future_date_str = fields.Datetime.to_string(future_date) + + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "String Date Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date_str, + "website_published": True, + } + ) + ) + + # Post should not be published because of future scheduled date + self.assertFalse( + post.website_published, + "Post with future scheduled date (string) should not be published", + ) + # Scheduled date should be stored and not False + self.assertTrue( + post.scheduled_publication_date, + "Scheduled date should be stored", + ) + # Verify the date is correct (allowing for small time differences) + stored_date = fields.Datetime.from_string( + fields.Datetime.to_string(post.scheduled_publication_date) + ) + expected_date = fields.Datetime.from_string(future_date_str) + self.assertEqual( + stored_date, + expected_date, + "Scheduled date should match the original date", + ) + + def test_write_without_scheduled_date_or_publication(self): + """Test that write without scheduled date or publication uses standard write.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Test Post", + "blog_id": self.blog.id, + } + ) + ) + # Write without scheduled date or publication should work normally + post.write({"name": "Updated Post Name"}) + self.assertEqual(post.name, "Updated Post Name") + + def test_write_set_past_date_on_unpublished_post(self): + """Test that setting a past date on unpublished post clears the date.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "website_published": False, + } + ) + ) + # Set a past date + past_date = fields.Datetime.now() - timedelta(hours=1) + post.write({"scheduled_publication_date": past_date}) + + # Past date should be cleared + self.assertFalse( + post.scheduled_publication_date, + "Past scheduled date should be cleared when set on unpublished post", + ) + + def test_write_publish_with_existing_past_scheduled_date(self): + """Test publishing a post that has an existing past scheduled date.""" + past_date = fields.Datetime.now() - timedelta(hours=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Post with Past Date", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": False, + } + ) + ) + # Publish the post (scheduled date exists but not in vals) + post.with_context(mail_create_nolog=True).write({"website_published": True}) + + # Post should be published and scheduled date cleared + self.assertTrue( + post.website_published, + "Post should be published when publishing with existing past scheduled date", + ) + self.assertFalse( + post.scheduled_publication_date, + "Past scheduled date should be cleared after publication", + ) + + def test_write_multiple_records_different_scheduled_dates(self): + """Test write with multiple records having different scheduled dates.""" + future_date = fields.Datetime.now() + timedelta(days=1) + past_date = fields.Datetime.now() - timedelta(hours=1) + + post1 = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Post 1", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "website_published": False, + } + ) + ) + post2 = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Post 2", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": False, + } + ) + ) + + # Try to publish both posts + posts = post1 | post2 + posts.with_context(mail_create_nolog=True).write({"website_published": True}) + + # Post1 should not be published (future date) + self.assertFalse( + post1.website_published, + "Post with future scheduled date should not be published", + ) + # Post2 should be published (past date) + self.assertTrue( + post2.website_published, + "Post with past scheduled date should be published", + ) + self.assertFalse( + post2.scheduled_publication_date, + "Past scheduled date should be cleared after publication", + ) + + def test_create_with_past_date_not_publishing(self): + """Test creating a post with past date but not publishing.""" + past_date = fields.Datetime.now() - timedelta(hours=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Post with Past Date", + "blog_id": self.blog.id, + "scheduled_publication_date": past_date, + "website_published": False, + } + ) + ) + # Past date should be kept for cron to handle + self.assertFalse(post.website_published, "Post should not be published") + self.assertEqual( + post.scheduled_publication_date, + past_date, + "Past scheduled date should be preserved when not publishing", + ) + + def test_create_with_future_date_not_publishing(self): + """Test creating a post with future date but not publishing.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Post with Future Date", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "website_published": False, + } + ) + ) + # Future date should be kept + self.assertFalse(post.website_published, "Post should not be published") + self.assertEqual( + post.scheduled_publication_date, + future_date, + "Future scheduled date should be preserved", + ) + + def test_write_set_past_date_on_published_post(self): + """Test that setting a past date on published post publishes immediately.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Published Post", + "blog_id": self.blog.id, + "website_published": True, + } + ) + ) + # Set a past scheduled date + past_date = fields.Datetime.now() - timedelta(hours=1) + post.with_context(mail_create_nolog=True).write( + {"scheduled_publication_date": past_date} + ) + + # Post should remain published and scheduled date cleared + self.assertTrue( + post.website_published, + "Post should remain published when past date is set", + ) + self.assertFalse( + post.scheduled_publication_date, + "Past scheduled date should be cleared", + ) + + def test_cron_empty_result(self): + """Test that cron handles empty result gracefully.""" + # Run cron when there are no scheduled posts + result = self.env["blog.post"]._publish_scheduled_posts() + self.assertTrue(result, "Cron should return True even with no posts") + + def test_check_for_publication_sends_notifications(self): + """Test that _check_for_publication sends notifications to blog followers.""" + # Count initial messages on blog + initial_message_count = len(self.blog.message_ids) + + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Test Post", + "blog_id": self.blog.id, + "website_published": False, + } + ) + ) + + # Call _check_for_publication directly + result = post._check_for_publication({"is_published": True}) + self.assertTrue(result, "Should return True when publishing") + + # Verify that notification message was created on blog + final_message_count = len(self.blog.message_ids) + self.assertGreater( + final_message_count, + initial_message_count, + "Notification should be sent when post is published", + ) + + def test_action_publish_now(self): + """Test action_publish_now method publishes immediately.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "is_scheduled": True, + "website_published": False, + } + ) + ) + self.assertFalse(post.website_published) + self.assertTrue(post.is_scheduled) + + # Call action_publish_now + post.with_context(mail_create_nolog=True).action_publish_now() + + # Post should be published immediately + self.assertTrue( + post.website_published, + "Post should be published after action_publish_now", + ) + self.assertFalse( + post.is_scheduled, + "is_scheduled should be False after action_publish_now", + ) + self.assertFalse( + post.scheduled_publication_date, + "Scheduled date should be cleared after action_publish_now", + ) + + def test_action_schedule(self): + """Test action_schedule method returns the correct action.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Test Post", + "blog_id": self.blog.id, + "website_published": False, + } + ) + ) + + # Call action_schedule + action = post.action_schedule() + + # Verify action structure + self.assertEqual( + action["res_model"], + "blog.post.schedule.date", + "Action should target blog.post.schedule.date wizard", + ) + self.assertEqual( + action["context"]["default_blog_post_id"], + post.id, + "Action context should include default_blog_post_id", + ) + self.assertEqual( + action["context"]["dialog_size"], + "medium", + "Action context should include dialog_size", + ) + + def test_action_schedule_with_existing_date(self): + """Test action_schedule with existing scheduled date.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "website_published": False, + } + ) + ) + + # Call action_schedule + action = post.action_schedule() + + # Verify action context includes existing scheduled date + self.assertEqual( + action["context"]["default_schedule_date"], + future_date, + "Action context should include existing scheduled date", + ) + + def test_wizard_action_schedule_date(self): + """Test the wizard action_schedule_date method.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Test Post", + "blog_id": self.blog.id, + "website_published": False, + } + ) + ) + + future_date = fields.Datetime.now() + timedelta(days=2) + wizard = self.env["blog.post.schedule.date"].create( + { + "blog_post_id": post.id, + "schedule_date": future_date, + } + ) + + # Call action_schedule_date + result = wizard.action_schedule_date() + + # Verify return value + self.assertTrue(result, "Wizard should return True") + + # Verify post was updated + self.assertTrue( + post.is_scheduled, + "Post is_scheduled should be True", + ) + self.assertEqual( + post.scheduled_publication_date, + future_date, + "Post scheduled_publication_date should be set", + ) + self.assertFalse( + post.website_published, + "Post should not be published", + ) + + def test_is_scheduled_change_to_scheduled(self): + """Test changing is_scheduled to 'scheduled' sets a default date.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Test Post", + "blog_id": self.blog.id, + "website_published": False, + } + ) + ) + self.assertFalse(post.is_scheduled) + self.assertFalse(post.scheduled_publication_date) + + # Change is_scheduled to True + post.write({"is_scheduled": True}) + + # Should set a default future date (tomorrow) + self.assertTrue(post.is_scheduled) + self.assertIsNotNone(post.scheduled_publication_date) + self.assertGreater( + post.scheduled_publication_date, + fields.Datetime.now(), + "Default scheduled date should be in the future", + ) + + def test_is_scheduled_change_to_now(self): + """Test changing is_scheduled to False clears the date.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "is_scheduled": True, + "website_published": False, + } + ) + ) + self.assertTrue(post.is_scheduled) + self.assertEqual(post.scheduled_publication_date, future_date) + + # Change is_scheduled to False + post.write({"is_scheduled": False}) + + # Should clear the scheduled date + self.assertFalse(post.is_scheduled) + self.assertFalse(post.scheduled_publication_date) + + def test_check_for_publication_with_future_scheduled_date(self): + """Test _check_for_publication skips posts with future scheduled date.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "website_published": False, + } + ) + ) + + # Call _check_for_publication + result = post._check_for_publication({"is_published": True}) + + # Should return False (no notifications for future scheduled posts) + self.assertFalse( + result, + "Should return False for posts with future scheduled date", + ) + + def test_check_for_publication_not_publishing(self): + """Test _check_for_publication returns False when not publishing.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Test Post", + "blog_id": self.blog.id, + "website_published": False, + } + ) + ) + + # Call _check_for_publication without is_published + result = post._check_for_publication({}) + + # Should return False + self.assertFalse( + result, + "Should return False when not publishing", + ) + + def test_check_for_publication_with_future_date_in_vals(self): + """Test _check_for_publication skips when future date is in vals.""" + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .create( + { + "name": "Test Post", + "blog_id": self.blog.id, + "website_published": False, + } + ) + ) + + future_date = fields.Datetime.now() + timedelta(days=1) + future_date_str = fields.Datetime.to_string(future_date) + + # Call _check_for_publication with future date in vals + result = post._check_for_publication( + {"is_published": True, "scheduled_publication_date": future_date_str} + ) + + # Should return False + self.assertFalse( + result, + "Should return False when future scheduled date is in vals", + ) + + def test_write_clear_scheduled_date(self): + """Test writing scheduled_publication_date to False clears it.""" + future_date = fields.Datetime.now() + timedelta(days=1) + post = ( + self.env["blog.post"] + .with_user(self.user_blogmanager) + .with_context(mail_create_nolog=True) + .create( + { + "name": "Scheduled Post", + "blog_id": self.blog.id, + "scheduled_publication_date": future_date, + "is_scheduled": True, + "website_published": False, + } + ) + ) + + # Clear the scheduled date + post.write({"scheduled_publication_date": False}) + + # Should be cleared and is_scheduled set to now + self.assertFalse(post.scheduled_publication_date) + self.assertFalse(post.is_scheduled) diff --git a/website_blog_scheduled_publication/views/blog_post_views.xml b/website_blog_scheduled_publication/views/blog_post_views.xml new file mode 100644 index 0000000000..cf162d522f --- /dev/null +++ b/website_blog_scheduled_publication/views/blog_post_views.xml @@ -0,0 +1,141 @@ + + + + + blog.post.form.scheduled + blog.post + + +
+ + + + + + + + + + + + + + + +
+ + + blog.post.search.scheduled + blog.post + + + + + + + + + + + + + + + + blog.post.calendar.scheduled + blog.post + + + + + + + + + + + + Scheduled Blog Posts + blog.post + calendar,tree,form + + [('scheduled_publication_date', '!=', False)] + {} + +

+ No scheduled posts found +

+

+ Schedule blog posts to see them in the calendar view. +

+
+
+
diff --git a/website_blog_scheduled_publication/wizard/__init__.py b/website_blog_scheduled_publication/wizard/__init__.py new file mode 100644 index 0000000000..901755dab7 --- /dev/null +++ b/website_blog_scheduled_publication/wizard/__init__.py @@ -0,0 +1 @@ +from . import blog_post_schedule_date diff --git a/website_blog_scheduled_publication/wizard/blog_post_schedule_date.py b/website_blog_scheduled_publication/wizard/blog_post_schedule_date.py new file mode 100644 index 0000000000..2d087f76d4 --- /dev/null +++ b/website_blog_scheduled_publication/wizard/blog_post_schedule_date.py @@ -0,0 +1,23 @@ +# Copyright 2026 - TODAY, Marcel Savegnago +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BlogPostScheduleDate(models.TransientModel): + _name = "blog.post.schedule.date" + _description = "Schedule a blog post publication" + + schedule_date = fields.Datetime(string="Publish on", required=True) + blog_post_id = fields.Many2one("blog.post", required=True) + + def action_schedule_date(self): + """Schedule the blog post for publication.""" + self.blog_post_id.write( + { + "is_scheduled": True, + "scheduled_publication_date": self.schedule_date, + "website_published": False, + } + ) + return True diff --git a/website_blog_scheduled_publication/wizard/blog_post_schedule_date_views.xml b/website_blog_scheduled_publication/wizard/blog_post_schedule_date_views.xml new file mode 100644 index 0000000000..e8cb01ae20 --- /dev/null +++ b/website_blog_scheduled_publication/wizard/blog_post_schedule_date_views.xml @@ -0,0 +1,40 @@ + + + + + blog.post.schedule.date.view.form + blog.post.schedule.date + +
+ + + + + +
+
+
+
+
+ + + When do you want to publish this blog post? + blog.post.schedule.date + ir.actions.act_window + form + new + +