diff --git a/taiga/projects/history/api.py b/taiga/projects/history/api.py index db1d6bbc..17c2fa83 100644 --- a/taiga/projects/history/api.py +++ b/taiga/projects/history/api.py @@ -168,6 +168,11 @@ class HistoryViewSet(ReadOnlyListViewSet): return self.response_for_queryset(qs) +class EpicHistory(HistoryViewSet): + content_type = "epics.epic" + permission_classes = (permissions.EpicHistoryPermission,) + + class UserStoryHistory(HistoryViewSet): content_type = "userstories.userstory" permission_classes = (permissions.UserStoryHistoryPermission,) diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 9b2dcadc..7d5b3543 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -106,6 +106,17 @@ def milestone_values(diff): return values +def epic_values(diff): + values = _common_users_values(diff) + + if "status" in diff: + values["status"] = _get_us_status_values(diff["status"]) + + # TODO EPICS: What happen with usr stories? + + return values + + def userstory_values(diff): values = _common_users_values(diff) @@ -190,6 +201,18 @@ def extract_attachments(obj) -> list: "order": attach.order} +@as_tuple +def extract_epic_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.epiccustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + @as_tuple def extract_user_story_custom_attributes(obj) -> list: with suppress(ObjectDoesNotExist): @@ -235,6 +258,7 @@ def project_freezer(project) -> dict: "total_milestones", "total_story_points", "tags", + "is_epics_activated", "is_backlog_activated", "is_kanban_activated", "is_wiki_activated", @@ -256,6 +280,31 @@ def milestone_freezer(milestone) -> dict: return snapshot +def epic_freezer(epic) -> dict: + snapshot = { + "ref": epic.ref, + "owner": epic.owner_id, + "status": epic.status.id if epic.status else None, + "is_closed": epic.is_closed, + "finish_date": str(epic.finish_date), + "epics_order": epic.epics_order, + "subject": epic.subject, + "description": epic.description, + "description_html": mdrender(epic.project, epic.description), + "assigned_to": epic.assigned_to_id, + "client_requirement": epic.client_requirement, + "team_requirement": epic.team_requirement, + "attachments": extract_attachments(epic), + "tags": epic.tags, + "is_blocked": epic.is_blocked, + "blocked_note": epic.blocked_note, + "blocked_note_html": mdrender(epic.project, epic.blocked_note), + "custom_attributes": extract_epic_custom_attributes(epic), + } + + return snapshot + + def userstory_freezer(us) -> dict: rp_cls = apps.get_model("userstories", "RolePoints") rpqsd = rp_cls.objects.filter(user_story=us) diff --git a/taiga/projects/history/permissions.py b/taiga/projects/history/permissions.py index e55ac3c4..40e4f98b 100644 --- a/taiga/projects/history/permissions.py +++ b/taiga/projects/history/permissions.py @@ -42,6 +42,14 @@ class IsCommentProjectAdmin(PermissionComponent): return is_project_admin(request.user, project) +class EpicHistoryPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter() + comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner() + + class UserStoryHistoryPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index b9526b08..54dd78fc 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -50,6 +50,7 @@ from .models import HistoryType # Freeze implementatitions from .freeze_impl import project_freezer from .freeze_impl import milestone_freezer +from .freeze_impl import epic_freezer from .freeze_impl import userstory_freezer from .freeze_impl import issue_freezer from .freeze_impl import task_freezer @@ -58,6 +59,7 @@ from .freeze_impl import wikipage_freezer from .freeze_impl import project_values from .freeze_impl import milestone_values +from .freeze_impl import epic_values from .freeze_impl import userstory_values from .freeze_impl import issue_values from .freeze_impl import task_values @@ -337,10 +339,7 @@ def take_snapshot(obj: object, *, comment: str="", user=None, delete: bool=False # If diff and comment are empty, do # not create empty history entry - if (not fdiff.diff and not comment - and old_fobj is not None - and entry_type != HistoryType.delete): - + if (not fdiff.diff and not comment and old_fobj is not None and entry_type != HistoryType.delete): return None fvals = make_diff_values(typename, fdiff) @@ -394,8 +393,10 @@ def prefetch_owners_in_history_queryset(qs): return qs +# Freeze & value register register_freeze_implementation("projects.project", project_freezer) register_freeze_implementation("milestones.milestone", milestone_freezer,) +register_freeze_implementation("epics.epic", epic_freezer) register_freeze_implementation("userstories.userstory", userstory_freezer) register_freeze_implementation("issues.issue", issue_freezer) register_freeze_implementation("tasks.task", task_freezer) @@ -403,6 +404,7 @@ register_freeze_implementation("wiki.wikipage", wikipage_freezer) register_values_implementation("projects.project", project_values) register_values_implementation("milestones.milestone", milestone_values) +register_values_implementation("epics.epic", epic_values) register_values_implementation("userstories.userstory", userstory_values) register_values_implementation("issues.issue", issue_values) register_values_implementation("tasks.task", task_values) diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 9a9c639d..58999578 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -30,15 +30,6 @@ from taiga.permissions.services import calculate_permissions from taiga.permissions.services import is_project_admin, is_project_owner from . import services -<<<<<<< HEAD -======= -from .custom_attributes.serializers import EpicCustomAttributeSerializer -from .custom_attributes.serializers import UserStoryCustomAttributeSerializer -from .custom_attributes.serializers import TaskCustomAttributeSerializer -from .custom_attributes.serializers import IssueCustomAttributeSerializer -from .likes.mixins.serializers import FanResourceSerializerMixin -from .mixins.serializers import ValidateDuplicatedNameInProjectMixin ->>>>>>> 5f3559d... Epic custom attributes values from .notifications.choices import NotifyLevel @@ -222,6 +213,7 @@ class ProjectSerializer(serializers.LightSerializer): members = MethodField() total_milestones = Field() total_story_points = Field() + is_epics_activated = Field() is_backlog_activated = Field() is_kanban_activated = Field() is_wiki_activated = Field() @@ -249,6 +241,7 @@ class ProjectSerializer(serializers.LightSerializer): tags = Field() tags_colors = MethodField() + default_epic_status = Field(attr="default_epic_status_id") default_points = Field(attr="default_points_id") default_us_status = Field(attr="default_us_status_id") default_task_status = Field(attr="default_task_status_id") @@ -300,14 +293,13 @@ class ProjectSerializer(serializers.LightSerializer): def get_my_permissions(self, obj): if "request" in self.context: user = self.context["request"].user - return calculate_permissions( - is_authenticated=user.is_authenticated(), - is_superuser=user.is_superuser, - is_member=self.get_i_am_member(obj), - is_admin=self.get_i_am_admin(obj), - role_permissions=obj.my_role_permissions_attr, - anon_permissions=obj.anon_permissions, - public_permissions=obj.public_permissions) + return calculate_permissions(is_authenticated=user.is_authenticated(), + is_superuser=user.is_superuser, + is_member=self.get_i_am_member(obj), + is_admin=self.get_i_am_admin(obj), + role_permissions=obj.my_role_permissions_attr, + anon_permissions=obj.anon_permissions, + public_permissions=obj.public_permissions) return [] def get_owner(self, obj): @@ -441,10 +433,8 @@ class ProjectDetailSerializer(ProjectSerializer): return len(obj.members_attr) def get_is_out_of_owner_limits(self, obj): - assert (hasattr(obj, "private_projects_same_owner_attr"), - "instance must have a private_projects_same_owner_attr attribute" - assert (hasattr(obj, "public_projects_same_owner_attr"), - "instance must have a public_projects_same_owner_attr attribute" + assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" + assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" return services.check_if_project_is_out_of_owner_limits( obj, current_memberships=self.get_total_memberships(obj), @@ -453,10 +443,8 @@ class ProjectDetailSerializer(ProjectSerializer): ) def get_is_private_extra_info(self, obj): - assert (hasattr(obj, "private_projects_same_owner_attr"), - "instance must have a private_projects_same_owner_attr attribute" - assert (hasattr(obj, "public_projects_same_owner_attr"), - "instance must have a public_projects_same_owner_attr attribute" + assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" + assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" return services.check_if_project_privacity_can_be_changed( obj, current_memberships=self.get_total_memberships(obj), diff --git a/taiga/routers.py b/taiga/routers.py index d8bc8b00..49db63b2 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -196,11 +196,13 @@ router.register(r"wiki-links", WikiLinkViewSet, # History & Components +from taiga.projects.history.api import EpicHistory from taiga.projects.history.api import UserStoryHistory from taiga.projects.history.api import TaskHistory from taiga.projects.history.api import IssueHistory from taiga.projects.history.api import WikiHistory +router.register(r"history/epic", EpicHistory, base_name="epic-history") router.register(r"history/userstory", UserStoryHistory, base_name="userstory-history") router.register(r"history/task", TaskHistory, base_name="task-history") router.register(r"history/issue", IssueHistory, base_name="issue-history") diff --git a/taiga/timeline/api.py b/taiga/timeline/api.py index 3e3bd6f4..2ebe5fa2 100644 --- a/taiga/timeline/api.py +++ b/taiga/timeline/api.py @@ -85,6 +85,7 @@ class TimelineViewSet(ReadOnlyListViewSet): event_type::text = ANY('{issues.issue.change, tasks.task.change, userstories.userstory.change, + epics.epic.change, wiki.wikipage.change}'::text[]) ) """]) @@ -92,6 +93,7 @@ class TimelineViewSet(ReadOnlyListViewSet): qs = qs.exclude(event_type__in=["issues.issue.delete", "tasks.task.delete", "userstories.userstory.delete", + "epics.epic.delete", "wiki.wikipage.delete", "projects.project.change"]) diff --git a/taiga/timeline/apps.py b/taiga/timeline/apps.py index 7b193552..fa951716 100644 --- a/taiga/timeline/apps.py +++ b/taiga/timeline/apps.py @@ -33,8 +33,8 @@ class TimelineAppConfig(AppConfig): sender=apps.get_model("history", "HistoryEntry"), dispatch_uid="timeline") signals.post_save.connect(handlers.create_membership_push_to_timeline, - sender=apps.get_model("projects", "Membership")) + sender=apps.get_model("projects", "Membership")) signals.pre_delete.connect(handlers.delete_membership_push_to_timeline, - sender=apps.get_model("projects", "Membership")) + sender=apps.get_model("projects", "Membership")) signals.post_save.connect(handlers.create_user_push_to_timeline, - sender=get_user_model()) + sender=get_user_model()) diff --git a/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py b/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py index 07290281..f3a5fa57 100644 --- a/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py +++ b/taiga/timeline/management/commands/_rebuild_timeline_for_user_creation.py @@ -25,8 +25,9 @@ from django.core.management.base import BaseCommand from django.db.models import Model from django.test.utils import override_settings -from taiga.timeline.service import (_get_impl_key_from_model, - _timeline_impl_map, extract_user_info) +from taiga.timeline.service import _get_impl_key_from_model, +from taiga.timeline.service import _timeline_impl_map, +from taiga.timeline.service import extract_user_info) from taiga.timeline.models import Timeline from taiga.timeline.signals import _push_to_timelines @@ -54,7 +55,8 @@ class BulkCreator(object): bulk_creator = BulkCreator() -def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}): +def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, + namespace:str="default", extra_data:dict={}): assert isinstance(obj, Model), "obj must be a instance of Model" assert isinstance(instance, Model), "instance must be a instance of Model" event_type_key = _get_impl_key_from_model(instance.__class__, event_type) diff --git a/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py b/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py index 090cf5f6..6c37b17c 100644 --- a/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py +++ b/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py @@ -40,7 +40,9 @@ def update_timeline(initial_date, final_date): print("Generating tasks indexed by id dict") task_ids = timelines.values_list("object_id", flat=True) - tasks_per_id = {task.id: task for task in Task.objects.filter(id__in=task_ids).select_related("user_story").iterator()} + + tasks_iterator = Task.objects.filter(id__in=task_ids).select_related("user_story").iterator() + tasks_per_id = {task.id: task for task in tasks_iterator} del task_ids counter = 1 diff --git a/taiga/timeline/management/commands/rebuild_timeline.py b/taiga/timeline/management/commands/rebuild_timeline.py index 947a7418..d3772a4f 100644 --- a/taiga/timeline/management/commands/rebuild_timeline.py +++ b/taiga/timeline/management/commands/rebuild_timeline.py @@ -58,7 +58,8 @@ class BulkCreator(object): bulk_creator = BulkCreator() -def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}): +def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, + namespace:str="default", extra_data:dict={}): assert isinstance(obj, Model), "obj must be a instance of Model" assert isinstance(instance, Model), "instance must be a instance of Model" event_type_key = _get_impl_key_from_model(instance.__class__, event_type) @@ -102,11 +103,13 @@ def generate_timeline(initial_date, final_date, project_id): if project_id: project = Project.objects.get(id=project_id) - us_keys = ['userstories.userstory:%s'%(id) for id in project.user_stories.values_list("id", flat=True)] + epic_keys = ['epics.epic:%s'%(id) for id in project.epics.values_list("id", flat=True)] + us_keys = ['userstories.userstory:%s'%(id) for id in project.user_stories.values_list("id", + flat=True)] tasks_keys = ['tasks.task:%s'%(id) for id in project.tasks.values_list("id", flat=True)] issue_keys = ['issues.issue:%s'%(id) for id in project.issues.values_list("id", flat=True)] wiki_keys = ['wiki.wikipage:%s'%(id) for id in project.wiki_pages.values_list("id", flat=True)] - keys = us_keys + tasks_keys + issue_keys + wiki_keys + keys = epic_keys + us_keys + tasks_keys + issue_keys + wiki_keys projects = projects.filter(id=project_id) history_entries = history_entries.filter(key__in=keys) @@ -121,7 +124,8 @@ def generate_timeline(initial_date, final_date, project_id): "values_diff": {}, "user": extract_user_info(project.owner), } - _push_to_timelines(project, project.owner, project, "create", project.created_date, extra_data=extra_data) + _push_to_timelines(project, project.owner, project, "create", project.created_date, + extra_data=extra_data) del extra_data for historyEntry in history_entries.iterator(): diff --git a/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py b/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py index 2f2ae1b5..f4c1a0a4 100644 --- a/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py +++ b/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py @@ -31,7 +31,7 @@ class Command(BaseCommand): total = Project.objects.count() for count,project in enumerate(Project.objects.order_by("id")): - print("""*********************************** - %s/%s %s -***********************************"""%(count+1, total, project.name)) + print("***********************************\n", + " {}/{} {}\n".format(count+1, total, project.name), + "***********************************") call_command("rebuild_timeline", project=project.id) diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py index d3e81976..06964eb3 100644 --- a/taiga/timeline/service.py +++ b/taiga/timeline/service.py @@ -52,7 +52,8 @@ def build_project_namespace(project: object): return "{0}:{1}".format("project", project.id) -def _add_to_object_timeline(obj: object, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): +def _add_to_object_timeline(obj: object, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): assert isinstance(obj, Model), "obj must be a instance of Model" assert isinstance(instance, Model), "instance must be a instance of Model" from .models import Timeline @@ -74,12 +75,14 @@ def _add_to_object_timeline(obj: object, instance: object, event_type: str, crea ) -def _add_to_objects_timeline(objects, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): +def _add_to_objects_timeline(objects, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): for obj in objects: _add_to_object_timeline(obj, instance, event_type, created_datetime, namespace, extra_data) -def _push_to_timeline(objects, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): +def _push_to_timeline(objects, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): if isinstance(objects, Model): _add_to_object_timeline(objects, instance, event_type, created_datetime, namespace, extra_data) elif isinstance(objects, QuerySet) or isinstance(objects, list): @@ -89,7 +92,8 @@ def _push_to_timeline(objects, instance: object, event_type: str, created_dateti @app.task -def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id, event_type, created_datetime, extra_data={}): +def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id, event_type, + created_datetime, extra_data={}): ObjModel = apps.get_model(obj_app_label, obj_model_name) try: obj = ObjModel.objects.get(id=obj_id) @@ -156,6 +160,7 @@ def filter_timeline_for_user(timeline, user): content_types = { "view_project": ContentType.objects.get_by_natural_key("projects", "project"), "view_milestones": ContentType.objects.get_by_natural_key("milestones", "milestone"), + "view_epic": ContentType.objects.get_by_natural_key("epics", "epic"), "view_us": ContentType.objects.get_by_natural_key("userstories", "userstory"), "view_tasks": ContentType.objects.get_by_natural_key("tasks", "task"), "view_issues": ContentType.objects.get_by_natural_key("issues", "issue"), @@ -181,7 +186,8 @@ def filter_timeline_for_user(timeline, user): if membership.is_admin: tl_filter |= Q(project=membership.project) else: - data_content_types = list(filter(None, [content_types.get(a, None) for a in membership.role.permissions])) + data_content_types = list(filter(None, [content_types.get(a, None) for a in + membership.role.permissions])) data_content_types.append(membership_content_type) tl_filter |= Q(project=membership.project, data_content_type__in=data_content_types) @@ -252,6 +258,14 @@ def extract_milestone_info(instance): } +def extract_epic_info(instance): + return { + "id": instance.pk, + "ref": instance.ref, + "subject": instance.subject, + } + + def extract_userstory_info(instance): return { "id": instance.pk, diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py index 1bee6c27..7f754b63 100644 --- a/taiga/timeline/signals.py +++ b/taiga/timeline/signals.py @@ -36,9 +36,23 @@ def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_d ct = ContentType.objects.get_for_model(obj) if settings.CELERY_ENABLED: - connection.on_commit(lambda: push_to_timelines.delay(project_id, user.id, ct.app_label, ct.model, obj.id, event_type, created_datetime, extra_data=extra_data)) + connection.on_commit(lambda: push_to_timelines.delay(project_id, + user.id, + ct.app_label, + ct.model, + obj.id, + event_type, + created_datetime, + extra_data=extra_data)) else: - push_to_timelines(project_id, user.id, ct.app_label, ct.model, obj.id, event_type, created_datetime, extra_data=extra_data) + push_to_timelines(project_id, + user.id, + ct.app_label, + ct.model, + obj.id, + event_type, + created_datetime, + extra_data=extra_data) def _clean_description_fields(values_diff): diff --git a/taiga/timeline/timeline_implementations.py b/taiga/timeline/timeline_implementations.py index e6065971..9b97fa06 100644 --- a/taiga/timeline/timeline_implementations.py +++ b/taiga/timeline/timeline_implementations.py @@ -43,6 +43,18 @@ def milestone_timeline(instance, extra_data={}): return result +@register_timeline_implementation("epics.epic", "create") +@register_timeline_implementation("epics.epic", "change") +@register_timeline_implementation("epics.epic", "delete") +def epic_timeline(instance, extra_data={}): + result ={ + "epic": service.extract_epic_info(instance), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + @register_timeline_implementation("userstories.userstory", "create") @register_timeline_implementation("userstories.userstory", "change") @register_timeline_implementation("userstories.userstory", "delete") diff --git a/tests/factories.py b/tests/factories.py index b5fdd880..8558907d 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -389,6 +389,16 @@ class IssueTypeFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") +class EpicCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.EpicCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Epic Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Epic Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + class UserStoryCustomAttributeFactory(Factory): class Meta: model = "custom_attributes.UserStoryCustomAttribute" @@ -419,6 +429,15 @@ class IssueCustomAttributeFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") +class EpicCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.EpicCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + epic = factory.SubFactory("tests.factories.EpicFactory") + + class UserStoryCustomAttributesValuesFactory(Factory): class Meta: model = "custom_attributes.UserStoryCustomAttributesValues"