예제 #1
0
class PlanTemplate(SlugMixin, TranslatableModel):
    name = models.CharField(max_length=100, blank=True)
    translations = TranslatedFields(
        preflight_message=MarkdownField(blank=True, property_suffix="_markdown"),
        post_install_message=MarkdownField(blank=True, property_suffix="_markdown"),
        error_message=MarkdownField(blank=True, property_suffix="_markdown"),
    )
    product = models.ForeignKey(Product, on_delete=models.PROTECT)

    slug_class = PlanSlug

    @property
    def preflight_message_markdown(self):
        return self._get_translated_model(use_fallback=True).preflight_message_markdown

    @property
    def post_install_message_markdown(self):
        return self._get_translated_model(
            use_fallback=True
        ).post_install_message_markdown

    @property
    def error_message_markdown(self):
        return self._get_translated_model(use_fallback=True).error_message_markdown

    def __str__(self):
        return f"{self.product.title}: {self.name}"
예제 #2
0
class SiteProfile(TranslatableModel):
    site = models.OneToOneField(Site, on_delete=models.CASCADE)

    translations = TranslatedFields(
        name=models.CharField(max_length=64),
        company_name=models.CharField(max_length=64, blank=True),
        welcome_text=MarkdownField(property_suffix="_markdown", blank=True),
        copyright_notice=MarkdownField(property_suffix="_markdown",
                                       blank=True),
    )

    product_logo = models.ImageField(blank=True)
    company_logo = models.ImageField(blank=True)
    favicon = models.ImageField(blank=True)

    @property
    def welcome_text_markdown(self):
        return self.get_translation("en-us").welcome_text_markdown

    @property
    def copyright_notice_markdown(self):
        return self.get_translation("en-us").copyright_notice_markdown

    def __str__(self):
        return self.name
예제 #3
0
def test_deconstruct__non_default_suffix():
    field = MarkdownField(property_suffix="_rendered")
    assert field.deconstruct() == (
        None,
        qual_name(MarkdownField),
        [],
        {"property_suffix": "_rendered"},
    )
예제 #4
0
def test_deconstruct__default_suffix():
    field = MarkdownField()
    assert field.deconstruct() == (
        None,
        "sfdo_template_helpers.fields.MarkdownField",
        [],
        {},
    )
예제 #5
0
def test_deconstruct__non_default_suffix():
    field = MarkdownField(property_suffix="_rendered")
    assert field.deconstruct() == (
        None,
        "sfdo_template_helpers.fields.MarkdownField",
        [],
        {"property_suffix": "_rendered"},
    )
예제 #6
0
class Markdowner(models.Model):
    description = MarkdownField(null=True)
    unsafe_description = MarkdownField(
        null=True,
        is_safe=False,
        allowed_tags=["p", "object"],
        allowed_attrs={"object": ["title", "src"]},
    )
    name = StringField(null=True, blank=True)
예제 #7
0
class SiteProfile(TranslatableModel):
    site = models.OneToOneField(Site, on_delete=models.CASCADE)

    translations = TranslatedFields(
        name=models.CharField(max_length=64),
        clickthrough_agreement=MarkdownField(property_suffix="_markdown", blank=True),
    )
예제 #8
0
class Plan(HashIdMixin, SlugMixin, AllowedListAccessMixin, TranslatableModel):
    Tier = Choices("primary", "secondary", "additional")

    translations = TranslatedFields(
        title=models.CharField(max_length=128),
        preflight_message_additional=MarkdownField(
            blank=True, property_suffix="_markdown"),
        post_install_message_additional=MarkdownField(
            blank=True, property_suffix="_markdown"),
    )

    plan_template = models.ForeignKey(PlanTemplate, on_delete=models.PROTECT)
    version = models.ForeignKey(Version, on_delete=models.PROTECT)
    preflight_flow_name = models.CharField(max_length=256, blank=True)
    tier = models.CharField(choices=Tier, default=Tier.primary, max_length=64)
    is_listed = models.BooleanField(default=True)

    slug_class = PlanSlug

    @property
    def preflight_message_additional_markdown(self):
        return self.get_translation(
            "en-us").preflight_message_additional_markdown

    @property
    def post_install_message_additional_markdown(self):
        return self.get_translation(
            "en-us").post_install_message_additional_markdown

    @property
    def required_step_ids(self):
        return self.steps.filter(is_required=True).values_list("id", flat=True)

    @property
    def slug_parent(self):
        return self.plan_template

    @property
    def slug_queryset(self):
        return self.plan_template.planslug_set

    def natural_key(self):
        return (self.version, self.title)

    def __str__(self):
        return "{}, Plan {}".format(self.version, self.title)
예제 #9
0
class AllowedList(models.Model):
    title = models.CharField(max_length=128, unique=True)
    description = MarkdownField(blank=True, property_suffix="_markdown")
    org_type = ArrayField(
        models.CharField(max_length=64, choices=ORG_TYPES),
        blank=True,
        size=4,
        default=list,
        help_text="All orgs of these types will be automatically allowed.",
    )
    list_for_allowed_by_orgs = models.BooleanField(
        default=False,
        help_text=
        ("If a user is allowed only because they have the right Org Type, should "
         "this be listed for them? If not, they can still find it if they happen to "
         "know the address."),
    )

    def __str__(self):
        return self.title
예제 #10
0
class Task(
        CreatePrMixin,
        PushMixin,
        HashIdMixin,
        TimestampsMixin,
        SlugMixin,
        SoftDeleteMixin,
        models.Model,
):
    name = StringField()
    epic = models.ForeignKey(Epic,
                             on_delete=models.PROTECT,
                             related_name="tasks")
    description = MarkdownField(blank=True, property_suffix="_markdown")
    branch_name = models.CharField(max_length=100,
                                   blank=True,
                                   default="",
                                   validators=[validate_unicode_branch])
    org_config_name = StringField()

    commits = models.JSONField(default=list, blank=True)
    origin_sha = StringField(blank=True, default="")
    metecho_commits = models.JSONField(default=list, blank=True)
    has_unmerged_commits = models.BooleanField(default=False)

    currently_creating_pr = models.BooleanField(default=False)
    pr_number = models.IntegerField(null=True, blank=True)
    pr_is_open = models.BooleanField(default=False)

    currently_submitting_review = models.BooleanField(default=False)
    review_submitted_at = models.DateTimeField(null=True, blank=True)
    review_valid = models.BooleanField(default=False)
    review_status = models.CharField(choices=TASK_REVIEW_STATUS,
                                     blank=True,
                                     default="",
                                     max_length=32)
    review_sha = StringField(blank=True, default="")
    reviewers = models.JSONField(default=list, blank=True)

    status = models.CharField(choices=TASK_STATUSES,
                              default=TASK_STATUSES.Planned,
                              max_length=16)

    # Assignee user data is shaped like this:
    #   {
    #     "id": str,
    #     "login": str,
    #     "avatar_url": str,
    #   }
    assigned_dev = models.JSONField(null=True, blank=True)
    assigned_qa = models.JSONField(null=True, blank=True)

    slug_class = TaskSlug
    tracker = FieldTracker(fields=["name"])

    def __str__(self):
        return self.name

    def save(self, *args, force_epic_save=False, **kwargs):
        ret = super().save(*args, **kwargs)
        # To update the epic's status:
        if force_epic_save or self.epic.should_update_status():
            self.epic.save()
            self.epic.notify_changed(originating_user_id=None)
        return ret

    def subscribable_by(self, user):  # pragma: nocover
        return True

    # begin SoftDeleteMixin configuration:
    def soft_delete_child_class(self):
        return ScratchOrg

    # end SoftDeleteMixin configuration

    # begin PushMixin configuration:
    push_update_type = "TASK_UPDATE"
    push_error_type = "TASK_CREATE_PR_FAILED"

    def get_serialized_representation(self, user):
        from .serializers import TaskSerializer

        return TaskSerializer(
            self, context=self._create_context_with_user(user)).data

    # end PushMixin configuration

    # begin CreatePrMixin configuration:
    create_pr_event = "TASK_CREATE_PR"

    @property
    def get_all_users_in_commits(self):
        ret = []
        for commit in self.commits:
            if commit["author"] not in ret:
                ret.append(commit["author"])
        ret.sort(key=lambda d: d["username"])
        return ret

    def add_reviewer(self, user):
        if user not in self.reviewers:
            self.reviewers.append(user)
            self.save()

    def get_repo_id(self):
        return self.epic.project.get_repo_id()

    def get_base(self):
        return self.epic.branch_name

    def get_head(self):
        return self.branch_name

    def try_to_notify_assigned_user(self):
        # This takes the tester (a.k.a. assigned_qa) and sends them an
        # email when a PR has been made.
        assigned = self.assigned_qa
        id_ = assigned.get("id") if assigned else None
        sa = SocialAccount.objects.filter(provider="github", uid=id_).first()
        user = getattr(sa, "user", None)
        if user:
            task = self
            epic = task.epic
            project = epic.project
            metecho_link = get_user_facing_url(
                path=["projects", project.slug, epic.slug, task.slug])
            subject = _("Metecho Task Submitted for Testing")
            body = render_to_string(
                "pr_created_for_task.txt",
                {
                    "task_name": task.name,
                    "epic_name": epic.name,
                    "project_name": project.name,
                    "assigned_user_name": user.username,
                    "metecho_link": metecho_link,
                },
            )
            user.notify(subject, body)

    # end CreatePrMixin configuration

    def update_review_valid(self):
        review_valid = bool(self.review_sha and self.commits
                            and self.review_sha == self.commits[0].get("id"))
        self.review_valid = review_valid

    def update_has_unmerged_commits(self):
        base = self.get_base()
        head = self.get_head()
        if head and base:
            repo = gh.get_repo_info(
                None,
                repo_owner=self.epic.project.repo_owner,
                repo_name=self.epic.project.repo_name,
            )
            base_sha = repo.branch(base).commit.sha
            head_sha = repo.branch(head).commit.sha
            self.has_unmerged_commits = (repo.compare_commits(
                base_sha, head_sha).ahead_by > 0)

    def finalize_task_update(self, *, originating_user_id):
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_status_completed(self, pr_number, *, originating_user_id):
        self.status = TASK_STATUSES.Completed
        self.has_unmerged_commits = False
        self.pr_number = pr_number
        self.pr_is_open = False
        self.epic.has_unmerged_commits = True
        # This will save the epic, too:
        self.save(force_epic_save=True)
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_pr_closed(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_open = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_pr_opened(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_open = True
        self.pr_is_merged = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_provision(self, *, originating_user_id):
        if self.status == TASK_STATUSES.Planned:
            self.status = TASK_STATUSES["In progress"]
            self.save()
            self.notify_changed(originating_user_id=originating_user_id)

    def finalize_commit_changes(self, *, originating_user_id):
        if self.status != TASK_STATUSES["In progress"]:
            self.status = TASK_STATUSES["In progress"]
            self.save()
            self.notify_changed(originating_user_id=originating_user_id)

    def add_commits(self, commits, sender):
        self.commits = [
            gh.normalize_commit(c, sender=sender) for c in commits
        ] + self.commits
        self.update_has_unmerged_commits()
        self.update_review_valid()
        self.save()
        # This comes from the GitHub hook, and so has no originating user:
        self.notify_changed(originating_user_id=None)

    def add_metecho_git_sha(self, sha):
        self.metecho_commits.append(sha)

    def queue_submit_review(self, *, user, data, originating_user_id):
        from .jobs import submit_review_job

        self.currently_submitting_review = True
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)
        submit_review_job.delay(user=user,
                                task=self,
                                data=data,
                                originating_user_id=originating_user_id)

    def finalize_submit_review(
        self,
        timestamp,
        *,
        error=None,
        sha=None,
        status="",
        delete_org=False,
        org=None,
        originating_user_id,
    ):
        self.currently_submitting_review = False
        if error:
            self.save()
            self.notify_error(
                error,
                type_="TASK_SUBMIT_REVIEW_FAILED",
                originating_user_id=originating_user_id,
            )
        else:
            self.review_submitted_at = timestamp
            self.review_status = status
            self.review_sha = sha
            self.update_review_valid()
            self.save()
            self.notify_changed(type_="TASK_SUBMIT_REVIEW",
                                originating_user_id=originating_user_id)
            deletable_org = (org and org.task == self
                             and org.org_type == SCRATCH_ORG_TYPES.QA)
            if delete_org and deletable_org:
                org.queue_delete(originating_user_id=originating_user_id)

    class Meta:
        ordering = ("-created_at", "name")
예제 #11
0
class Epic(
        CreatePrMixin,
        PushMixin,
        HashIdMixin,
        TimestampsMixin,
        SlugMixin,
        SoftDeleteMixin,
        models.Model,
):
    name = StringField()
    description = MarkdownField(blank=True, property_suffix="_markdown")
    branch_name = models.CharField(max_length=100,
                                   blank=True,
                                   default="",
                                   validators=[validate_unicode_branch])
    has_unmerged_commits = models.BooleanField(default=False)
    currently_creating_pr = models.BooleanField(default=False)
    pr_number = models.IntegerField(null=True, blank=True)
    pr_is_open = models.BooleanField(default=False)
    pr_is_merged = models.BooleanField(default=False)
    status = models.CharField(max_length=20,
                              choices=EPIC_STATUSES,
                              default=EPIC_STATUSES.Planned)
    # List of {
    #   "key": str,
    #   "label": str,
    #   "description": str,
    # }
    available_task_org_config_names = models.JSONField(default=list,
                                                       blank=True)
    currently_fetching_org_config_names = models.BooleanField(default=False)

    project = models.ForeignKey(Project,
                                on_delete=models.PROTECT,
                                related_name="epics")

    # User data is shaped like this:
    #   {
    #     "id": str,
    #     "login": str,
    #     "avatar_url": str,
    #   }
    github_users = models.JSONField(default=list, blank=True)

    slug_class = EpicSlug
    tracker = FieldTracker(fields=["name"])

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        self.update_status()
        return super().save(*args, **kwargs)

    def subscribable_by(self, user):  # pragma: nocover
        return True

    # begin SoftDeleteMixin configuration:
    def soft_delete_child_class(self):
        return Task

    # end SoftDeleteMixin configuration

    # begin PushMixin configuration:
    push_update_type = "EPIC_UPDATE"
    push_error_type = "EPIC_CREATE_PR_FAILED"

    def get_serialized_representation(self, user):
        from .serializers import EpicSerializer

        return EpicSerializer(
            self, context=self._create_context_with_user(user)).data

    # end PushMixin configuration

    # begin CreatePrMixin configuration:
    create_pr_event = "EPIC_CREATE_PR"

    def get_repo_id(self):
        return self.project.get_repo_id()

    def get_base(self):
        return self.project.branch_name

    def get_head(self):
        return self.branch_name

    def try_to_notify_assigned_user(self):  # pragma: nocover
        # Does nothing in this case.
        pass

    # end CreatePrMixin configuration

    def create_gh_branch(self, user):
        from .jobs import create_gh_branch_for_new_epic_job

        create_gh_branch_for_new_epic_job.delay(self, user=user)

    def should_update_in_progress(self):
        task_statuses = self.tasks.values_list("status", flat=True)
        return task_statuses and any(status != TASK_STATUSES.Planned
                                     for status in task_statuses)

    def should_update_review(self):
        task_statuses = self.tasks.values_list("status", flat=True)
        return task_statuses and all(status == TASK_STATUSES.Completed
                                     for status in task_statuses)

    def should_update_merged(self):
        return self.pr_is_merged

    def should_update_status(self):
        if self.should_update_merged():
            return self.status != EPIC_STATUSES.Merged
        elif self.should_update_review():
            return self.status != EPIC_STATUSES.Review
        elif self.should_update_in_progress():
            return self.status != EPIC_STATUSES["In progress"]
        return False

    def update_status(self):
        if self.should_update_merged():
            self.status = EPIC_STATUSES.Merged
        elif self.should_update_review():
            self.status = EPIC_STATUSES.Review
        elif self.should_update_in_progress():
            self.status = EPIC_STATUSES["In progress"]

    def finalize_pr_closed(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_open = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_pr_opened(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_open = True
        self.pr_is_merged = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_epic_update(self, *, originating_user_id):
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_status_completed(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_merged = True
        self.has_unmerged_commits = False
        self.pr_is_open = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def queue_available_task_org_config_names(self, user):
        from .jobs import available_task_org_config_names_job

        self.currently_fetching_org_config_names = True
        self.save()
        self.notify_changed(originating_user_id=str(user.id))
        available_task_org_config_names_job.delay(self, user=user)

    def finalize_available_task_org_config_names(self,
                                                 originating_user_id=None):
        self.currently_fetching_org_config_names = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    class Meta:
        ordering = ("-created_at", "name")
예제 #12
0
class Project(
        PushMixin,
        PopulateRepoIdMixin,
        HashIdMixin,
        TimestampsMixin,
        SlugMixin,
        models.Model,
):
    repo_owner = StringField()
    repo_name = StringField()
    name = StringField(unique=True)
    description = MarkdownField(blank=True, property_suffix="_markdown")
    is_managed = models.BooleanField(default=False)
    repo_id = models.IntegerField(null=True, blank=True, unique=True)
    repo_image_url = models.URLField(blank=True)
    include_repo_image_url = models.BooleanField(default=True)
    branch_name = models.CharField(
        max_length=100,
        blank=True,
        validators=[validate_unicode_branch],
        default="master",
    )
    branch_prefix = StringField(blank=True)
    # User data is shaped like this:
    #   {
    #     "id": str,
    #     "login": str,
    #     "avatar_url": str,
    #   }
    github_users = models.JSONField(default=list, blank=True)

    slug_class = ProjectSlug
    tracker = FieldTracker(fields=["name"])

    def subscribable_by(self, user):  # pragma: nocover
        return True

    # begin PushMixin configuration:
    push_update_type = "PROJECT_UPDATE"
    push_error_type = "PROJECT_UPDATE_ERROR"

    def get_serialized_representation(self, user):
        from .serializers import ProjectSerializer

        return ProjectSerializer(
            self, context=self._create_context_with_user(user)).data

    # end PushMixin configuration

    def __str__(self):
        return self.name

    class Meta:
        ordering = ("name", )
        unique_together = (("repo_owner", "repo_name"), )

    def save(self, *args, **kwargs):
        if not self.branch_name:
            repo = gh.get_repo_info(None,
                                    repo_owner=self.repo_owner,
                                    repo_name=self.repo_name)
            self.branch_name = repo.default_branch

        if not self.github_users:
            self.queue_populate_github_users(originating_user_id=None)

        if not self.repo_image_url:
            from .jobs import get_social_image_job

            get_social_image_job.delay(project=self)

        super().save(*args, **kwargs)

    def finalize_get_social_image(self):
        self.save()
        self.notify_changed(originating_user_id=None)

    def queue_populate_github_users(self, *, originating_user_id):
        from .jobs import populate_github_users_job

        populate_github_users_job.delay(
            self, originating_user_id=originating_user_id)

    def finalize_populate_github_users(self,
                                       *,
                                       error=None,
                                       originating_user_id):
        if error is None:
            self.save()
            self.notify_changed(originating_user_id=originating_user_id)
        else:
            self.notify_error(error, originating_user_id=originating_user_id)

    def queue_refresh_commits(self, *, ref, originating_user_id):
        from .jobs import refresh_commits_job

        refresh_commits_job.delay(project=self,
                                  branch_name=ref,
                                  originating_user_id=originating_user_id)

    @transaction.atomic
    def add_commits(self, *, commits, ref, sender):
        matching_tasks = Task.objects.filter(branch_name=ref,
                                             epic__project=self)

        for task in matching_tasks:
            task.add_commits(commits, sender)
예제 #13
0
class AllowedList(models.Model):
    title = models.CharField(max_length=128, unique=True)
    description = MarkdownField(blank=True, property_suffix="_markdown")

    def __str__(self):
        return self.title
예제 #14
0
class Markdowner(models.Model):
    description = MarkdownField(null=True)
예제 #15
0
class Project(
    PushMixin,
    PopulateRepoIdMixin,
    HashIdMixin,
    TimestampsMixin,
    SlugMixin,
    models.Model,
):
    repo_owner = StringField()
    repo_name = StringField()
    name = StringField(unique=True)
    description = MarkdownField(blank=True, property_suffix="_markdown")
    is_managed = models.BooleanField(default=False)
    repo_id = models.IntegerField(null=True, blank=True, unique=True)
    repo_image_url = models.URLField(blank=True)
    include_repo_image_url = models.BooleanField(default=True)
    branch_name = models.CharField(
        max_length=100,
        blank=True,
        validators=[validate_unicode_branch],
    )
    branch_prefix = StringField(blank=True)
    # User data is shaped like this:
    #   {
    #     "id": str,
    #     "login": str,
    #     "name": str,
    #     "avatar_url": str,
    #     "permissions": {
    #       "push": bool,
    #       "pull": bool,
    #       "admin": bool,
    #     },
    #   }
    github_users = models.JSONField(default=list, blank=True)
    # List of {
    #   "key": str,
    #   "label": str,
    #   "description": str,
    # }
    org_config_names = models.JSONField(default=list, blank=True)
    currently_fetching_org_config_names = models.BooleanField(default=False)
    currently_fetching_github_users = models.BooleanField(default=False)
    latest_sha = StringField(blank=True)

    slug_class = ProjectSlug
    tracker = FieldTracker(fields=["name"])

    def subscribable_by(self, user):  # pragma: nocover
        return True

    def get_absolute_url(self):
        # See src/js/utils/routes.ts
        return f"/projects/{self.slug}"

    # begin PushMixin configuration:
    push_update_type = "PROJECT_UPDATE"
    push_error_type = "PROJECT_UPDATE_ERROR"

    def get_serialized_representation(self, user):
        from .serializers import ProjectSerializer

        return ProjectSerializer(
            self, context=self._create_context_with_user(user)
        ).data

    # end PushMixin configuration

    def __str__(self):
        return self.name

    class Meta:
        ordering = ("name",)
        unique_together = (("repo_owner", "repo_name"),)

    def save(self, *args, **kwargs):
        if not self.branch_name:
            repo = gh.get_repo_info(
                None, repo_owner=self.repo_owner, repo_name=self.repo_name
            )
            self.branch_name = repo.default_branch
            self.latest_sha = repo.branch(repo.default_branch).latest_sha()

        if not self.latest_sha:
            repo = gh.get_repo_info(
                None, repo_owner=self.repo_owner, repo_name=self.repo_name
            )
            self.latest_sha = repo.branch(self.branch_name).latest_sha()

        super().save(*args, **kwargs)

    def finalize_get_social_image(self):
        self.save()
        self.notify_changed(originating_user_id=None)

    def queue_refresh_github_users(self, *, originating_user_id):
        from .jobs import refresh_github_users_job

        if not self.currently_fetching_github_users:
            self.currently_fetching_github_users = True
            self.save()
            refresh_github_users_job.delay(
                self, originating_user_id=originating_user_id
            )

    def finalize_refresh_github_users(self, *, error=None, originating_user_id):
        self.currently_fetching_github_users = False
        self.save()
        if error is None:
            self.notify_changed(originating_user_id=originating_user_id)
        else:
            self.notify_error(error, originating_user_id=originating_user_id)

    def queue_refresh_commits(self, *, ref, originating_user_id):
        from .jobs import refresh_commits_job

        refresh_commits_job.delay(
            project=self, branch_name=ref, originating_user_id=originating_user_id
        )

    def queue_available_org_config_names(self, user=None):
        from .jobs import available_org_config_names_job

        self.currently_fetching_org_config_names = True
        self.save()
        self.notify_changed(originating_user_id=str(user.id) if user else None)
        available_org_config_names_job.delay(self, user=user)

    def finalize_available_org_config_names(self, originating_user_id=None):
        self.currently_fetching_org_config_names = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_project_update(self, *, originating_user_id=None):
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    @transaction.atomic
    def add_commits(self, *, commits, ref, sender):
        if self.branch_name == ref:
            self.latest_sha = commits[0].get("id") if commits else ""
            self.finalize_project_update()

        matching_epics = Epic.objects.filter(branch_name=ref, project=self)
        for epic in matching_epics:
            epic.add_commits(commits)

        matching_tasks = Task.objects.filter(branch_name=ref, epic__project=self)
        for task in matching_tasks:
            task.add_commits(commits, sender)

    def has_push_permission(self, user):
        return GitHubRepository.objects.filter(
            user=user,
            repo_id=self.repo_id,
            permissions__push=True,
        ).exists()

    def get_collaborator(self, gh_uid: str) -> Optional[Dict[str, object]]:
        try:
            return [u for u in self.github_users if u["id"] == gh_uid][0]
        except IndexError:
            return None
예제 #16
0
class Plan(HashIdMixin, SlugMixin, AllowedListAccessMixin, TranslatableModel):
    Tier = Choices("primary", "secondary", "additional")

    translations = TranslatedFields(
        title=models.CharField(max_length=128),
        preflight_message_additional=MarkdownField(
            blank=True, property_suffix="_markdown"),
        post_install_message_additional=MarkdownField(
            blank=True, property_suffix="_markdown"),
    )

    plan_template = models.ForeignKey(PlanTemplate, on_delete=models.PROTECT)
    version = models.ForeignKey(Version, on_delete=models.PROTECT)
    commit_ish = models.CharField(
        max_length=256,
        null=True,
        blank=True,
        help_text=_(
            "This is usually a tag, sometimes a branch. "
            "Use this to optionally override the Version's commit_ish."),
    )

    tier = models.CharField(choices=Tier, default=Tier.primary, max_length=64)
    is_listed = models.BooleanField(default=True)
    preflight_checks = JSONField(default=list, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    slug_class = PlanSlug
    slug_field_name = "title"

    @property
    def preflight_message_additional_markdown(self):
        return self._get_translated_model(
            use_fallback=True).preflight_message_additional_markdown

    @property
    def post_install_message_additional_markdown(self):
        return self._get_translated_model(
            use_fallback=True).post_install_message_additional_markdown

    @property
    def required_step_ids(self):
        return self.steps.filter(is_required=True).values_list("id", flat=True)

    @property
    def slug_parent(self):
        return self.plan_template

    @property
    def slug_queryset(self):
        return self.plan_template.planslug_set

    @property
    def average_duration(self):
        durations = [(job.success_at - job.enqueued_at)
                     for job in Job.objects.filter(
                         plan=self, status=Job.Status.complete).exclude(
                             Q(success_at__isnull=True)
                             | Q(enqueued_at__isnull=True)).order_by(
                                 "-created_at")[:settings.AVERAGE_JOB_WINDOW]]
        if len(durations) < settings.MINIMUM_JOBS_FOR_AVERAGE:
            return None
        return median(durations)

    def natural_key(self):
        return (self.version, self.title)

    def __str__(self):
        return "{}, Plan {}".format(self.version, self.title)

    @property
    def requires_preflight(self):
        has_plan_checks = bool(self.preflight_checks)
        has_step_checks = any(
            step.task_config.get("checks") for step in self.steps.iterator())
        return has_plan_checks or has_step_checks

    def get_translation_strategy(self):
        return (
            "fields",
            f"{self.plan_template.product.slug}:plan:{self.plan_template.name}",
        )

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

        from ..adminapi.translations import update_translations

        update_translations(self.plan_template.product)
        update_translations(self.plan_template)
        update_translations(self)
예제 #17
0
class ScratchOrg(
    SoftDeleteMixin, PushMixin, HashIdMixin, TimestampsMixin, models.Model
):
    project = models.ForeignKey(
        Project,
        on_delete=models.PROTECT,
        related_name="orgs",
        null=True,
        blank=True,
    )
    epic = models.ForeignKey(
        Epic,
        on_delete=models.PROTECT,
        related_name="orgs",
        null=True,
        blank=True,
    )
    task = models.ForeignKey(
        Task,
        on_delete=models.PROTECT,
        related_name="orgs",
        null=True,
        blank=True,
    )
    description = MarkdownField(blank=True, property_suffix="_markdown")
    org_type = StringField(choices=SCRATCH_ORG_TYPES)
    org_config_name = StringField()
    owner = models.ForeignKey(User, on_delete=models.PROTECT)
    last_modified_at = models.DateTimeField(null=True, blank=True)
    expires_at = models.DateTimeField(null=True, blank=True)
    latest_commit = StringField(blank=True)
    latest_commit_url = models.URLField(blank=True)
    latest_commit_at = models.DateTimeField(null=True, blank=True)
    url = models.URLField(blank=True, default="")
    last_checked_unsaved_changes_at = models.DateTimeField(null=True, blank=True)
    unsaved_changes = models.JSONField(
        default=dict, encoder=DjangoJSONEncoder, blank=True
    )
    ignored_changes = models.JSONField(
        default=dict, encoder=DjangoJSONEncoder, blank=True
    )
    latest_revision_numbers = models.JSONField(
        default=dict, encoder=DjangoJSONEncoder, blank=True
    )
    currently_refreshing_changes = models.BooleanField(default=False)
    currently_capturing_changes = models.BooleanField(default=False)
    currently_refreshing_org = models.BooleanField(default=False)
    currently_reassigning_user = models.BooleanField(default=False)
    is_created = models.BooleanField(default=False)
    config = models.JSONField(default=dict, encoder=DjangoJSONEncoder, blank=True)
    delete_queued_at = models.DateTimeField(null=True, blank=True)
    expiry_job_id = StringField(blank=True, default="")
    owner_sf_username = StringField(blank=True)
    owner_gh_username = StringField(blank=True)
    owner_gh_id = StringField(null=True, blank=True)
    has_been_visited = models.BooleanField(default=False)
    valid_target_directories = models.JSONField(
        default=dict, encoder=DjangoJSONEncoder, blank=True
    )
    cci_log = models.TextField(blank=True)

    def _build_message_extras(self):
        return {
            "model": {
                "task": str(self.task.id) if self.task else None,
                "epic": str(self.epic.id) if self.epic else None,
                "project": str(self.project.id) if self.project else None,
                "org_type": self.org_type,
                "id": str(self.id),
            }
        }

    def subscribable_by(self, user):  # pragma: nocover
        return True

    @property
    def parent(self):
        return self.project or self.epic or self.task

    @property
    def root_project(self):
        if self.project:
            return self.project
        if self.epic:
            return self.epic.project
        if self.task:
            return self.task.epic.project
        return None

    def save(self, *args, **kwargs):
        is_new = self.id is None
        self.clean_config()
        ret = super().save(*args, **kwargs)

        if is_new:
            self.queue_provision(originating_user_id=str(self.owner.id))
            self.notify_org_provisioning(originating_user_id=str(self.owner.id))

        return ret

    def clean(self):
        if len([x for x in [self.project, self.epic, self.task] if x is not None]) != 1:
            raise ValidationError(
                _("A ScratchOrg must belong to either a project, an epic, or a task.")
            )
        if self.org_type != SCRATCH_ORG_TYPES.Playground and not self.task:
            raise ValidationError(
                {"org_type": _("Dev and Test orgs must belong to a task.")}
            )
        return super().clean()

    def clean_config(self):
        banned_keys = {"email", "access_token", "refresh_token"}
        self.config = {k: v for (k, v) in self.config.items() if k not in banned_keys}

    def mark_visited(self, *, originating_user_id):
        self.has_been_visited = True
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def get_refreshed_org_config(self, org_name=None, keychain=None):
        org_config = refresh_access_token(
            scratch_org=self,
            config=self.config,
            org_name=org_name or self.org_config_name,
            keychain=keychain,
        )
        return org_config

    def get_login_url(self):
        org_config = self.get_refreshed_org_config()
        return org_config.start_url

    # begin PushMixin configuration:
    push_update_type = "SCRATCH_ORG_UPDATE"
    push_error_type = "SCRATCH_ORG_ERROR"

    def get_serialized_representation(self, user):
        from .serializers import ScratchOrgSerializer

        return ScratchOrgSerializer(
            self, context=self._create_context_with_user(user)
        ).data

    # end PushMixin configuration

    def queue_delete(self, *, originating_user_id):
        from .jobs import delete_scratch_org_job

        # If the scratch org has no `last_modified_at`, it did not
        # successfully complete the initial flow run on Salesforce, and
        # therefore we don't need to notify of its destruction; this
        # should only happen when it is destroyed during the initial
        # flow run.
        if self.last_modified_at:
            self.delete_queued_at = timezone.now()
            self.save()
            self.notify_changed(originating_user_id=originating_user_id)

        delete_scratch_org_job.delay(self, originating_user_id=originating_user_id)

    def finalize_delete(self, *, originating_user_id):
        self.notify_changed(
            type_="SCRATCH_ORG_DELETE",
            originating_user_id=originating_user_id,
            message=self._build_message_extras(),
        )

    def delete(self, *args, should_finalize=True, originating_user_id=None, **kwargs):
        # If the scratch org has no `last_modified_at`, it did not
        # successfully complete the initial flow run on Salesforce, and
        # therefore we don't need to notify of its destruction; this
        # should only happen when it is destroyed during provisioning or
        # the initial flow run.
        if self.last_modified_at and should_finalize:
            self.finalize_delete(originating_user_id=originating_user_id)
        super().delete(*args, **kwargs)

    def queue_provision(self, *, originating_user_id):
        from .jobs import create_branches_on_github_then_create_scratch_org_job

        create_branches_on_github_then_create_scratch_org_job.delay(
            scratch_org=self, originating_user_id=originating_user_id
        )

    def finalize_provision(self, *, error=None, originating_user_id):
        if error is None:
            self.save()
            self.notify_changed(
                type_="SCRATCH_ORG_PROVISION", originating_user_id=originating_user_id
            )
            if self.task:
                self.task.finalize_provision(originating_user_id=originating_user_id)
        else:
            self.notify_scratch_org_error(
                error=error,
                type_="SCRATCH_ORG_PROVISION_FAILED",
                originating_user_id=originating_user_id,
                message=self._build_message_extras(),
            )
            # If the scratch org has already been created on Salesforce,
            # we need to delete it there as well.
            if self.url:
                self.queue_delete(originating_user_id=originating_user_id)
            else:
                self.delete(originating_user_id=originating_user_id)

    def queue_convert_to_dev_org(self, task, *, originating_user_id=None):
        from .jobs import convert_to_dev_org_job

        convert_to_dev_org_job.delay(
            scratch_org=self, task=task, originating_user_id=originating_user_id
        )

    def finalize_convert_to_dev_org(self, task, *, error=None, originating_user_id):
        if error:
            self.notify_scratch_org_error(
                error=error,
                type_="SCRATCH_ORG_CONVERT_FAILED",
                originating_user_id=originating_user_id,
            )
            return

        self.org_type = SCRATCH_ORG_TYPES.Dev
        self.task = task
        self.epic = None
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def queue_get_unsaved_changes(self, *, force_get=False, originating_user_id):
        from .jobs import get_unsaved_changes_job

        minutes_since_last_check = (
            self.last_checked_unsaved_changes_at is not None
            and timezone.now() - self.last_checked_unsaved_changes_at
        )
        should_bail = (
            not force_get
            and minutes_since_last_check
            and minutes_since_last_check
            < timedelta(minutes=settings.ORG_RECHECK_MINUTES)
        )
        if should_bail:
            return

        self.currently_refreshing_changes = True
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

        get_unsaved_changes_job.delay(self, originating_user_id=originating_user_id)

    def finalize_get_unsaved_changes(self, *, error=None, originating_user_id):
        self.currently_refreshing_changes = False
        if error is None:
            self.last_checked_unsaved_changes_at = timezone.now()
            self.save()
            self.notify_changed(originating_user_id=originating_user_id)
        else:
            self.unsaved_changes = {}
            self.save()
            self.notify_scratch_org_error(
                error=error,
                type_="SCRATCH_ORG_FETCH_CHANGES_FAILED",
                originating_user_id=originating_user_id,
            )

    def queue_commit_changes(
        self,
        *,
        user,
        desired_changes,
        commit_message,
        target_directory,
        originating_user_id,
    ):
        from .jobs import commit_changes_from_org_job

        self.currently_capturing_changes = True
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

        commit_changes_from_org_job.delay(
            scratch_org=self,
            user=user,
            desired_changes=desired_changes,
            commit_message=commit_message,
            target_directory=target_directory,
            originating_user_id=originating_user_id,
        )

    def finalize_commit_changes(self, *, error=None, originating_user_id):
        self.currently_capturing_changes = False
        self.save()
        if error is None:
            self.notify_changed(
                type_="SCRATCH_ORG_COMMIT_CHANGES",
                originating_user_id=originating_user_id,
            )
            if self.task:
                self.task.finalize_commit_changes(
                    originating_user_id=originating_user_id
                )
        else:
            self.notify_scratch_org_error(
                error=error,
                type_="SCRATCH_ORG_COMMIT_CHANGES_FAILED",
                originating_user_id=originating_user_id,
            )

    def remove_scratch_org(self, error, *, originating_user_id):
        self.notify_scratch_org_error(
            error=error,
            type_="SCRATCH_ORG_REMOVE",
            originating_user_id=originating_user_id,
            message=self._build_message_extras(),
        )
        # set should_finalize=False to avoid accidentally sending a
        # SCRATCH_ORG_DELETE event:
        self.delete(should_finalize=False, originating_user_id=originating_user_id)

    def queue_refresh_org(self, *, originating_user_id):
        from .jobs import refresh_scratch_org_job

        self.has_been_visited = False
        self.currently_refreshing_org = True
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)
        refresh_scratch_org_job.delay(self, originating_user_id=originating_user_id)

    def finalize_refresh_org(self, *, error=None, originating_user_id):
        self.currently_refreshing_org = False
        self.save()
        if error is None:
            self.notify_changed(
                type_="SCRATCH_ORG_REFRESH", originating_user_id=originating_user_id
            )
        else:
            self.notify_scratch_org_error(
                error=error,
                type_="SCRATCH_ORG_REFRESH_FAILED",
                originating_user_id=originating_user_id,
                message=self._build_message_extras(),
            )
            self.queue_delete(originating_user_id=originating_user_id)

    def queue_reassign(self, *, new_user, originating_user_id):
        from .jobs import user_reassign_job

        self.currently_reassigning_user = True
        was_deleted = self.deleted_at is not None
        self.deleted_at = None
        self.save()
        if was_deleted:
            self.notify_changed(
                type_="SCRATCH_ORG_RECREATE",
                originating_user_id=originating_user_id,
                for_list=True,
            )
        user_reassign_job.delay(
            self, new_user=new_user, originating_user_id=originating_user_id
        )

    def finalize_reassign(self, *, error=None, originating_user_id):
        self.currently_reassigning_user = False
        self.save()
        if error is None:
            self.notify_changed(
                type_="SCRATCH_ORG_REASSIGN", originating_user_id=originating_user_id
            )
        else:
            self.notify_scratch_org_error(
                error=error,
                type_="SCRATCH_ORG_REASSIGN_FAILED",
                originating_user_id=originating_user_id,
            )
            self.delete()

    def notify_org_provisioning(self, originating_user_id):
        parent = self.parent
        if parent:
            group_name = CHANNELS_GROUP_NAME.format(
                model=parent._meta.model_name, id=parent.id
            )
            self.notify_changed(
                type_="SCRATCH_ORG_PROVISIONING",
                originating_user_id=originating_user_id,
                group_name=group_name,
            )
예제 #18
0
def test_deconstruct__default_suffix():
    field = MarkdownField()
    assert field.deconstruct() == (None, qual_name(MarkdownField), [], {})
예제 #19
0
class Product(HashIdMixin, SlugMixin, AllowedListAccessMixin,
              TranslatableModel):
    SLDS_ICON_CHOICES = (
        ("", ""),
        ("action", "action"),
        ("custom", "custom"),
        ("doctype", "doctype"),
        ("standard", "standard"),
        ("utility", "utility"),
    )

    class Meta:
        ordering = ("category__order_key", "order_key")

    objects = ProductQuerySet.as_manager()

    translations = TranslatedFields(
        title=models.CharField(max_length=256),
        short_description=models.TextField(blank=True),
        description=MarkdownField(property_suffix="_markdown", blank=True),
        click_through_agreement=MarkdownField(blank=True,
                                              property_suffix="_markdown"),
    )

    @property
    def description_markdown(self):
        return self.get_translation("en-us").description_markdown

    @property
    def click_through_agreement_markdown(self):
        return self.get_translation("en-us").click_through_agreement_markdown

    category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT)
    color = ColorField(blank=True)
    image = models.ImageField(blank=True)
    icon_url = models.URLField(
        blank=True,
        help_text=_(
            "This will take precedence over Color and the SLDS Icons."),
    )
    slds_icon_category = models.CharField(choices=SLDS_ICON_CHOICES,
                                          default="",
                                          blank=True,
                                          max_length=32)
    slds_icon_name = models.CharField(max_length=64, blank=True)
    repo_url = models.URLField(blank=True)
    is_listed = models.BooleanField(default=True)
    order_key = models.PositiveIntegerField(default=0)

    slug_class = ProductSlug

    @property
    def slug_queryset(self):
        return self.productslug_set

    def __str__(self):
        return self.title

    @property
    def most_recent_version(self):
        return self.version_set.exclude(
            is_listed=False).order_by("-created_at").first()

    @property
    def icon(self):
        if self.icon_url:
            return {"type": "url", "url": self.icon_url}
        if self.slds_icon_name and self.slds_icon_category:
            return {
                "type": "slds",
                "category": self.slds_icon_category,
                "name": self.slds_icon_name,
            }
        return None
예제 #20
0
class Plan(HashIdMixin, SlugMixin, AllowedListAccessMixin, TranslatableModel):
    Tier = Choices("primary", "secondary", "additional")

    translations = TranslatedFields(
        title=models.CharField(max_length=128),
        preflight_message_additional=MarkdownField(
            blank=True, property_suffix="_markdown"
        ),
        post_install_message_additional=MarkdownField(
            blank=True, property_suffix="_markdown"
        ),
    )

    plan_template = models.ForeignKey(PlanTemplate, on_delete=models.PROTECT)
    version = models.ForeignKey(Version, on_delete=models.PROTECT)
    tier = models.CharField(choices=Tier, default=Tier.primary, max_length=64)
    is_listed = models.BooleanField(default=True)
    preflight_checks = JSONField(default=list, blank=True)

    slug_class = PlanSlug
    slug_field_name = "title"

    @property
    def preflight_message_additional_markdown(self):
        return self._get_translated_model(
            use_fallback=True
        ).preflight_message_additional_markdown

    @property
    def post_install_message_additional_markdown(self):
        return self._get_translated_model(
            use_fallback=True
        ).post_install_message_additional_markdown

    @property
    def required_step_ids(self):
        return self.steps.filter(is_required=True).values_list("id", flat=True)

    @property
    def slug_parent(self):
        return self.plan_template

    @property
    def slug_queryset(self):
        return self.plan_template.planslug_set

    @property
    def average_duration(self):
        durations = [
            (job.success_at - job.enqueued_at)
            for job in Job.objects.filter(plan=self, status=Job.Status.complete)
            .exclude(Q(success_at__isnull=True) | Q(enqueued_at__isnull=True))
            .order_by("-created_at")[: settings.AVERAGE_JOB_WINDOW]
        ]
        if len(durations) < settings.MINIMUM_JOBS_FOR_AVERAGE:
            return None
        return median(durations)

    def natural_key(self):
        return (self.version, self.title)

    def __str__(self):
        return "{}, Plan {}".format(self.version, self.title)

    @property
    def requires_preflight(self):
        has_plan_checks = bool(self.preflight_checks)
        has_step_checks = any(
            step.task_config.get("checks") for step in self.steps.iterator()
        )
        return has_plan_checks or has_step_checks
예제 #21
0
class Epic(
    CreatePrMixin,
    PushMixin,
    HashIdMixin,
    TimestampsMixin,
    SlugMixin,
    SoftDeleteMixin,
    models.Model,
):
    name = StringField()
    description = MarkdownField(blank=True, property_suffix="_markdown")
    branch_name = models.CharField(
        max_length=100, blank=True, default="", validators=[validate_unicode_branch]
    )
    has_unmerged_commits = models.BooleanField(default=False)
    currently_creating_branch = models.BooleanField(default=False)
    currently_creating_pr = models.BooleanField(default=False)
    pr_number = models.IntegerField(null=True, blank=True)
    pr_is_open = models.BooleanField(default=False)
    pr_is_merged = models.BooleanField(default=False)
    status = models.CharField(
        max_length=20, choices=EPIC_STATUSES, default=EPIC_STATUSES.Planned
    )
    latest_sha = StringField(blank=True)

    project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="epics")
    github_users = models.JSONField(default=list, blank=True)

    slug_class = EpicSlug
    tracker = FieldTracker(fields=["name"])

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        self.update_status()
        return super().save(*args, **kwargs)

    def subscribable_by(self, user):  # pragma: nocover
        return True

    def get_absolute_url(self):
        # See src/js/utils/routes.ts
        return f"/projects/{self.project.slug}/{self.slug}"

    # begin SoftDeleteMixin configuration:
    def soft_delete_child_class(self):
        return Task

    # end SoftDeleteMixin configuration

    # begin PushMixin configuration:
    push_update_type = "EPIC_UPDATE"
    push_error_type = "EPIC_CREATE_PR_FAILED"

    def get_serialized_representation(self, user):
        from .serializers import EpicSerializer

        return EpicSerializer(self, context=self._create_context_with_user(user)).data

    # end PushMixin configuration

    # begin CreatePrMixin configuration:
    create_pr_event = "EPIC_CREATE_PR"

    def get_repo_id(self):
        return self.project.get_repo_id()

    def get_base(self):
        return self.project.branch_name

    def get_head(self):
        return self.branch_name

    def try_to_notify_assigned_user(self):  # pragma: nocover
        # Does nothing in this case.
        pass

    # end CreatePrMixin configuration

    def has_push_permission(self, user):
        return self.project.has_push_permission(user)

    def create_gh_branch(self, user):
        from .jobs import create_gh_branch_for_new_epic_job

        create_gh_branch_for_new_epic_job.delay(self, user=user)

    def should_update_in_progress(self):
        task_statuses = self.tasks.values_list("status", flat=True)
        return task_statuses and any(
            status != TASK_STATUSES.Planned for status in task_statuses
        )

    def should_update_review(self):
        """
        Returns truthy if:
            - there is at least one completed task
            - all tasks are completed or canceled
        """
        task_statuses = self.tasks.values_list("status", flat=True)
        return (
            task_statuses
            and all(
                status in [TASK_STATUSES.Completed, TASK_STATUSES.Canceled]
                for status in task_statuses
            )
            and any(status == TASK_STATUSES.Completed for status in task_statuses)
        )

    def should_update_merged(self):
        return self.pr_is_merged

    def should_update_status(self):
        if self.should_update_merged():
            return self.status != EPIC_STATUSES.Merged
        elif self.should_update_review():
            return self.status != EPIC_STATUSES.Review
        elif self.should_update_in_progress():
            return self.status != EPIC_STATUSES["In progress"]
        return False

    def update_status(self):
        if self.should_update_merged():
            self.status = EPIC_STATUSES.Merged
        elif self.should_update_review():
            self.status = EPIC_STATUSES.Review
        elif self.should_update_in_progress():
            self.status = EPIC_STATUSES["In progress"]

    def finalize_pr_closed(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_open = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_pr_opened(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_open = True
        self.pr_is_merged = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_epic_update(self, *, originating_user_id=None):
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def finalize_status_completed(self, pr_number, *, originating_user_id):
        self.pr_number = pr_number
        self.pr_is_merged = True
        self.has_unmerged_commits = False
        self.pr_is_open = False
        self.save()
        self.notify_changed(originating_user_id=originating_user_id)

    def add_commits(self, commits):
        self.latest_sha = commits[0].get("id") if commits else ""
        self.finalize_epic_update()

    class Meta:
        ordering = ("-created_at", "name")