예제 #1
0
파일: models.py 프로젝트: tmax818/spare
class DropoffTime(models.Model):
    time_start = models.TimeField()
    time_end = models.TimeField()
    location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name='dropoff_times')
    day = EnumIntegerField(DaysOfWeek, default=DaysOfWeek.SUNDAY)

    def get_visible_dates(self):
        results = []

        now = timezone.now()

        # Get number of days differences to next date
        days_ahead = self.day.value - now.weekday()
        if days_ahead <= 0:
            days_ahead += 7

        # Get the nearest valid date, given day and start time
        nearest_date = now.date() + timedelta(days=days_ahead)

        # We want to check the start time (plus an additional range of 1 day/24 hours)
        nearest_date_start_time = (
            datetime.combine(nearest_date, self.time_start) + timedelta(hours=HOURS_AHEAD)
        )

        # If that datetime has passed, start with the next week's date
        if timezone.make_aware(nearest_date_start_time) < now:
            nearest_date = nearest_date + timedelta(days=7)

        # Get a date for each visible week
        for weeks in range(VISIBLE_WEEKS):
            results.append(nearest_date + timedelta(days=7 * weeks))

        return results

    def __str__(self):
        return '{} - {}'.format(self.location, self.time_start)
예제 #2
0
파일: orders.py 프로젝트: krisera/shoop
class OrderStatus(TranslatableModel):
    identifier = InternalIdentifierField(db_index=True,
                                         blank=False,
                                         unique=True)
    ordering = models.IntegerField(db_index=True, default=0)
    role = EnumIntegerField(OrderStatusRole,
                            db_index=True,
                            default=OrderStatusRole.NONE)
    default = models.BooleanField(default=False, db_index=True)

    objects = OrderStatusQuerySet.as_manager()

    translations = TranslatedFields(
        name=models.CharField(verbose_name=_(u"Name"), max_length=64))

    def __str__(self):
        return self.safe_translation_getter("name", default=self.identifier)

    def save(self, *args, **kwargs):
        super(OrderStatus, self).save(*args, **kwargs)
        if self.default and self.role != OrderStatusRole.NONE:
            # If this status is the default, make the others for this role non-default.
            OrderStatus.objects.filter(role=self.role).exclude(
                pk=self.pk).update(default=False)
예제 #3
0
class Carousel(ShuupModel):
    name = models.CharField(
        max_length=50, verbose_name=_("name"), help_text=_("Name is only used to configure carousels.")
    )
    animation = EnumIntegerField(
        CarouselMode, default=CarouselMode.SLIDE, verbose_name=_("animation"),
        help_text=_("Animation type for cycling slides.")
    )
    interval = models.IntegerField(
        default=5, verbose_name=_("interval"), help_text=_("Slide interval in seconds.")
    )
    pause_on_hover = models.BooleanField(
        default=True, verbose_name=_("pause on hover"),
        help_text=_("Pauses the cycling of the carousel on mouse over.")
    )
    is_arrows_visible = models.BooleanField(default=True, verbose_name=_("show navigation arrows"))
    use_dot_navigation = models.BooleanField(default=True, verbose_name=_("show navigation dots"))
    image_width = models.IntegerField(
        default=1200, verbose_name=_("image width"),
        help_text=_("Slide images will be cropped to this width.")
    )
    image_height = models.IntegerField(
        default=600, verbose_name=_("image height"),
        help_text=_("Slide images will be cropped to this height.")
    )

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = _("Carousel")
        verbose_name_plural = _("Carousels")

    @property
    def animation_class_name(self):
        return "fade" if self.animation == CarouselMode.FADE else "slide"
예제 #4
0
class ERU(models.Model):
    """ A resource that can be deployed """
    type = EnumIntegerField(ERUType, default=0)
    units = models.IntegerField(default=0)
    equipment_units = models.IntegerField(default=0)
    # where deployed (none if available)
    deployed_to = models.ForeignKey(Country,
                                    null=True,
                                    blank=True,
                                    on_delete=models.SET_NULL)
    event = models.ForeignKey(Event,
                              null=True,
                              blank=True,
                              on_delete=models.SET_NULL)
    # links to services
    eru_owner = models.ForeignKey(ERUOwner, on_delete=models.CASCADE)
    available = models.BooleanField(default=False)

    def __str__(self):
        return [
            'Basecamp', 'IT & Telecom', 'Logistics', 'RCRC Emergency Hospital',
            'RCRC Emergency Clinic', 'Relief', 'WASH M15', 'WASH MSM20',
            'WASH M40'
        ][self.type]
예제 #5
0
class SubPlan(models.Model):
    """
    Model: 任务/子计划
    Fields: 创建时间(created), 创建者(owner), 任务标题(title), 任务详情(detail),
            任务日期时间(date), 是否完成(isDone)
    """
    user = models.ForeignKey(CustomUser, verbose_name='用户', blank=True, null=True, on_delete=models.CASCADE)
    plan = models.ForeignKey(Plan, verbose_name='计划', blank=False, null=False, on_delete=models.CASCADE)
    
    title = models.CharField('标题', max_length=100, blank=True, default='新任务')
    content = models.TextField('内容', blank=True, default='')
    
    label = EnumIntegerField(Label, verbose_name='重要紧急标签', default=Label.IMPORTANT_URGENT)

    created_datetime = models.DateTimeField('创建时间', auto_now_add=True)
    update_datetime = models.DateTimeField('修改时间', auto_now=True)
    
    is_archived = models.BooleanField('是否归档', default=False)
    
    class Meta:
        db_table = "SubPlans"
        ordering = ('update_datetime', )
        verbose_name = '子计划'
        verbose_name_plural = verbose_name
예제 #6
0
class BatchDomain(models.Model):
    """
    Table to hold the domains being registered for batch testing.

    """
    domain = models.CharField(max_length=255, default="")
    batch_request = models.ForeignKey(BatchRequest, related_name="domains")
    status = EnumIntegerField(BatchDomainStatus,
                              default=BatchDomainStatus.waiting,
                              db_index=True)
    status_changed = models.DateTimeField(default=timezone.now)
    webtest = models.ForeignKey('BatchWebTest', null=True)
    mailtest = models.ForeignKey('BatchMailTest', null=True)

    def get_batch_test(self):
        if self.webtest:
            return self.webtest
        return self.mailtest

    def __dir__(self):
        return [
            'domain', 'batch_result', 'status', 'status_changed', 'webtest',
            'mailtest'
        ]
예제 #7
0
class Wishlist(ShuupModel):
    shop = models.ForeignKey(Shop,
                             on_delete=models.CASCADE,
                             verbose_name=_('shop'))
    customer = models.ForeignKey(Contact,
                                 on_delete=models.CASCADE,
                                 verbose_name=_('customer'))
    name = models.CharField(verbose_name=_('name'), max_length=50)
    privacy = EnumIntegerField(WishlistPrivacy,
                               default=WishlistPrivacy.PRIVATE,
                               verbose_name=_('privacy'))
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_('created on'))
    products = models.ManyToManyField(ShopProduct,
                                      related_name='wishlists',
                                      verbose_name=_('products'))

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = _('Wishlist')
        verbose_name_plural = _('Wishlists')
예제 #8
0
class MyModel(models.Model):
    color = EnumField(Color, max_length=1)
    taste = EnumField(Taste, default=Taste.SWEET)
    taste_null_default = EnumField(Taste, null=True, blank=True, default=None)
    taste_int = EnumIntegerField(Taste, default=Taste.SWEET)

    default_none = EnumIntegerField(Taste, default=None, null=True, blank=True)
    nullable = EnumIntegerField(Taste, null=True, blank=True)

    random_code = models.TextField(null=True, blank=True)

    zero_field = EnumIntegerField(ZeroEnum,
                                  null=True,
                                  default=None,
                                  blank=True)
    int_enum = EnumIntegerField(IntegerEnum,
                                null=True,
                                default=None,
                                blank=True)

    zero2 = EnumIntegerField(ZeroEnum, default=ZeroEnum.ZERO)
    labeled_enum = EnumField(LabeledEnum, blank=True, null=True)
예제 #9
0
class Content(models.Model):
    text = models.TextField(_("Text"), blank=True)

    # It would be nice to use UUIDField but in practise this could be anything due to other server implementations
    guid = models.CharField(_("GUID"), max_length=255, unique=True)
    author = models.ForeignKey(Profile,
                               on_delete=models.CASCADE,
                               verbose_name=_("Author"))
    visibility = EnumIntegerField(Visibility,
                                  default=Visibility.PUBLIC,
                                  db_index=True)

    # Is this content pinned to the user profile
    pinned = models.BooleanField(_("Pinned to profile"),
                                 default=False,
                                 db_index=True)
    # Order int to allow ordering content within some context, for example profile
    order = models.PositiveIntegerField(verbose_name=_("Order"),
                                        default=1,
                                        db_index=True)

    # For example mobile, server or application name
    service_label = models.CharField(_("Service label"),
                                     blank=True,
                                     max_length=32)

    # oEmbed or preview based on OG tags
    oembed = models.ForeignKey(OEmbedCache,
                               verbose_name=_("OEmbed cache"),
                               on_delete=models.SET_NULL,
                               null=True)
    opengraph = models.ForeignKey(OpenGraphCache,
                                  verbose_name=_("OpenGraph cache"),
                                  on_delete=models.SET_NULL,
                                  null=True)

    tags = models.ManyToManyField(Tag,
                                  verbose_name=_("Tags"),
                                  related_name="contents")

    parent = models.ForeignKey(
        "self",
        on_delete=models.CASCADE,
        verbose_name=_("Parent"),
        related_name="children",
        null=True,
        blank=True,
    )

    share_of = models.ForeignKey(
        "self",
        on_delete=models.CASCADE,
        verbose_name=_("Share of"),
        related_name="shares",
        null=True,
        blank=True,
    )

    remote_created = models.DateTimeField(_("Remote created"),
                                          blank=True,
                                          null=True)
    created = AutoCreatedField(_('Created'), db_index=True)
    modified = AutoLastModifiedField(_('Modified'))

    # Cached data on save
    content_type = EnumIntegerField(ContentType,
                                    default=ContentType.CONTENT,
                                    db_index=True,
                                    editable=False)
    local = models.BooleanField(_("Local"), default=False, editable=False)
    rendered = models.TextField(_("Rendered text"), blank=True, editable=False)
    reply_count = models.PositiveIntegerField(_("Reply count"),
                                              default=0,
                                              editable=False)
    shares_count = models.PositiveIntegerField(_("Shares count"),
                                               default=0,
                                               editable=False)

    objects = ContentQuerySet.as_manager()

    def __str__(self):
        return "{text} ({guid})".format(text=truncate_letters(self.text, 100),
                                        guid=self.guid)

    def cache_data(self, commit=False):
        """Calculate some extra data."""
        # Local
        self.local = self.author.user is not None
        if self.pk:
            # Reply count
            share_ids = Content.objects.filter(share_of=self).values_list(
                "id", flat=True)
            self.reply_count = self.children.count() + Content.objects.filter(
                parent_id__in=share_ids).count()
            # Share count
            self.shares_count = self.shares.count()
            if commit:
                Content.objects.filter(id=self.id).update(
                    local=self.local,
                    reply_count=self.reply_count,
                    shares_count=self.shares_count,
                )

    def cache_related_object_data(self):
        """Update parent/shared_of cached data, for example share count"""
        if self.share_of:
            self.share_of.cache_data(commit=True)
        if self.parent:
            self.parent.cache_data(commit=True)
            if self.parent.share_of:
                self.parent.share_of.cache_data(commit=True)

    def get_absolute_url(self):
        if self.slug:
            return reverse("content:view-by-slug",
                           kwargs={
                               "pk": self.id,
                               "slug": self.slug
                           })
        return reverse("content:view", kwargs={"pk": self.id})

    @property
    def humanized_timestamp(self):
        """Human readable timestamp ie '2 hours ago'."""
        return arrow.get(self.modified).humanize()

    @cached_property
    def root(self):
        """Get root content if a reply or share."""
        if self.content_type == ContentType.CONTENT:
            return self
        elif self.content_type == ContentType.REPLY:
            return self.parent.root
        elif self.content_type == ContentType.SHARE:
            return self.share_of.root

    @property
    def timestamp(self):
        return arrow.get(self.modified).format()

    @property
    def url(self):
        return "%s%s" % (settings.SOCIALHOME_URL, self.get_absolute_url())

    @staticmethod
    @memoize(timeout=604800)  # a week
    def has_shared(content_id, profile_id):
        return Content.objects.filter(id=content_id,
                                      shares__author_id=profile_id).exists()

    def save(self, *args, **kwargs):
        if self.parent and self.share_of:
            raise ValueError("Can't be both a reply and a share!")
        self.cache_data()

        if self.parent:
            self.content_type = ContentType.REPLY
            # Ensure replies have sane values
            self.visibility = self.parent.visibility
            self.pinned = False
        elif self.share_of:
            self.content_type = ContentType.SHARE

        if not self.pk:
            if not self.guid:
                self.guid = uuid4()
            if self.pinned:
                max_order = Content.objects.top_level().filter(
                    author=self.author).aggregate(Max("order"))["order__max"]
                if max_order is not None:  # If max_order is None, there is likely to be no content yet
                    self.order = max_order + 1

        self.fix_local_uploads()
        super().save(*args, **kwargs)
        self.cache_related_object_data()

    def save_tags(self, tags):
        """Save given tag relations."""
        current = set(self.tags.values_list("name", flat=True))
        if tags == current:
            return
        to_add = tags - current
        tags_to_add = []
        for tag_name in to_add:
            tag, _created = Tag.objects.get_or_create(name=tag_name)
            tags_to_add.append(tag)
        final_tags = tags_to_add + list(
            Tag.objects.filter(name__in=tags & current))
        self.tags.set(final_tags)

    def share(self, profile):
        """Share this content as the profile given."""
        if self.content_type != ContentType.CONTENT:
            # TODO: support sharing replies too
            raise ValidationError("Can only share top level content.")
        if self.author == profile:
            raise ValidationError("Cannot share own content")
        if not self.visible_for_user(profile.user):
            raise ValidationError(
                "Content to be shared is not visible to sharer.")
        if self.shares.filter(author=profile).exists():
            raise ValidationError("Profile has already shared this content.")
        # Use get or created as a safety to stop duplicates
        share, _created = Content.objects.get_or_create(author=profile,
                                                        share_of=self,
                                                        defaults={
                                                            "visibility":
                                                            self.visibility,
                                                        })
        delete_memoized(Content.has_shared, self.id, profile.id)
        return share

    def unshare(self, profile):
        """Unshare this content as the profile given."""
        if not self.shares.filter(author=profile).exists():
            raise ValidationError("No share found.")
        try:
            share = Content.objects.get(author=profile, share_of=self)
        except Content.DoesNotExist:
            # Something got before us
            pass
        else:
            share.delete()
            delete_memoized(Content.has_shared, self.id, profile.id)

    @cached_property
    def is_nsfw(self):
        return self.text.lower().find("#nsfw") > -1

    @property
    def effective_created(self):
        if self.remote_created:
            return self.remote_created
        return self.created

    @property
    def edited(self):
        """Determine whether Content has been edited.

        Because we do multiple saves in some cases on creation, for example for oEmbed or OpenGraph,
        and a remote content could be delivered multiple times within a short time period, for example via
        relay and original node, we allow 15 minutes before deciding that the content has been edited.

        TODO: it would make sense to store an "edited" flag on the model itself.
        """
        return self.modified > self.created + datetime.timedelta(minutes=15)

    @cached_property
    def short_text(self):
        return truncate_letters(self.text, 50) or ""

    @property
    def short_text_inline(self):
        return self.short_text.replace("\n", " ").replace("\r", "")

    @cached_property
    def slug(self):
        return slugify(self.short_text)

    @cached_property
    def channel_group_name(self):
        """Make a safe Channel group name.

        ASCII or hyphens or periods only.
        Prefix with ID as we have to cut long guids due to asgi library group name restriction.
        """
        return ("%s_%s" % (self.id, slugify(self.guid)))[:80]

    def render(self):
        """Pre-render text to Content.rendered."""
        text = self.get_and_linkify_tags()
        rendered = commonmark(text).strip()
        rendered = process_text_links(rendered)
        if self.is_nsfw:
            rendered = make_nsfw_safe(rendered)
        if self.oembed:
            rendered = "%s<br>%s" % (rendered, self.oembed.oembed)
        if self.opengraph:
            rendered = "%s%s" % (rendered,
                                 render_to_string(
                                     "content/_og_preview.html",
                                     {"opengraph": self.opengraph}))
        self.rendered = rendered
        Content.objects.filter(id=self.id).update(rendered=rendered)

    def get_and_linkify_tags(self):
        """Find tags in text and convert them to Markdown links.

        Save found tags to the content.
        """
        found_tags = set()
        lines = self.text.splitlines(keepends=True)
        final_words = []
        code_block = False
        # Check each line separately
        for line in lines:
            if line[0:3] == "```":
                code_block = not code_block
            # noinspection PyTypeChecker
            if line.find("#") == -1 or line[0:4] == "    " or code_block:
                # Just add the whole line
                final_words.append(line)
                continue
            # Check each word separately
            # noinspection PyTypeChecker
            words = line.split(" ")
            for word in words:
                # noinspection PyTypeChecker
                candidate = word.strip().strip("([]),.!?:")
                # noinspection PyTypeChecker
                if candidate.startswith("#"):
                    # noinspection PyTypeChecker
                    candidate = candidate.strip("#")
                    if test_tag(candidate.lower()):
                        # Tag
                        found_tags.add(candidate.lower())
                        try:
                            # noinspection PyTypeChecker
                            tag_word = word.replace(
                                "#%s" % candidate, "[#%s](%s)" %
                                (candidate,
                                 reverse("streams:tag",
                                         kwargs={"name": candidate.lower()})))
                            final_words.append(tag_word)
                        except NoReverseMatch:
                            # Don't linkify, seems we can't generate an url for it
                            # TODO: throw to opbeat or just logger?
                            final_words.append(word)
                    else:
                        # Not tag
                        final_words.append(word)
                else:
                    final_words.append(word)
        text = " ".join(final_words)
        self.save_tags(found_tags)
        return text

    @staticmethod
    def get_rendered_contents(qs, user, throughs=None):
        """Get JSON serialized contents.

        :param qs: QuerySet
        :param user: User object
        :param throughs: Optional dict containing through id's
        """
        rendered = []
        for content in qs:
            through = throughs.get(content.id) if throughs else None
            rendered.append(content.dict_for_view(user, through=through))
        return rendered

    def fix_local_uploads(self):
        """Fix the markdown URL of local uploads.

        Basically these need to be remote compatible. So make this:

            ![](/media/markdownx/12345.jpg

        to this:

            ![](https://socialhome.domain/media/markdownx/12345.jpg
        """
        self.text = re.sub(r"!\[\]\(/media/uploads/",
                           "![](%s/media/uploads/" % settings.SOCIALHOME_URL,
                           self.text)

    def dict_for_view(self, user, through=None):
        if not through:
            through = self.id
        humanized_timestamp = "%s (edited)" % self.humanized_timestamp if self.edited else self.humanized_timestamp
        is_author = bool(user.is_authenticated and self.author == user.profile)
        is_following_author = bool(
            user.is_authenticated
            and self.author_id in user.profile.following_ids)
        profile_id = user.profile.id if getattr(user, "profile", None) else ""
        return {
            "author":
            self.author_id,
            "author_guid":
            self.author.guid,
            "author_handle":
            self.author.handle,
            "author_home_url":
            self.author.home_url,
            "author_image":
            self.author.safer_image_url_small,
            "author_is_local":
            self.local,
            "author_name":
            escape(self.author.name) or self.author.handle,
            "author_profile_url":
            self.author.get_absolute_url(),
            "reply_count":
            self.reply_count,
            "content_type":
            self.content_type.string_value,
            "delete_url":
            reverse("content:delete", kwargs={"pk": self.id})
            if is_author else "",
            "detail_url":
            self.get_absolute_url(),
            "formatted_timestamp":
            self.timestamp,
            "guid":
            self.guid,
            "has_shared":
            Content.has_shared(self.id, profile_id) if profile_id else False,
            "humanized_timestamp":
            humanized_timestamp,
            "id":
            self.id,
            "is_authenticated":
            bool(user.is_authenticated),
            "is_author":
            is_author,
            "is_following_author":
            is_following_author,
            "parent":
            self.parent_id if self.content_type == ContentType.REPLY else "",
            "profile_id":
            profile_id,
            "rendered":
            self.rendered,
            "reply_url":
            reverse("content:reply", kwargs={"pk": self.id})
            if user.is_authenticated else "",
            "shares_count":
            self.shares_count,
            "slug":
            self.slug,
            "through":
            through,
            "update_url":
            reverse("content:update", kwargs={"pk": self.id})
            if is_author else "",
        }

    def visible_for_user(self, user):
        """Check if visible to given user.

        Mirrors logic in `ContentQuerySet.visible_for_user`.
        """
        # TODO: handle also LIMITED when contacts implemented
        if self.visibility == Visibility.PUBLIC:
            return True
        if user.is_authenticated and (self.author == user.profile
                                      or self.visibility == Visibility.SITE):
            return True
        return False
예제 #10
0
class Notification(models.Model):
    """
    A model for persistent notifications to be shown in the admin, etc.
    """
    recipient_type = EnumIntegerField(RecipientType, default=RecipientType.ADMINS, verbose_name=_('recipient type'))
    recipient = models.ForeignKey(
        settings.AUTH_USER_MODEL, blank=True, null=True, related_name="+", on_delete=models.SET_NULL,
        verbose_name=_('recipient')
    )
    created_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('created on'))
    message = models.CharField(max_length=140, editable=False, default="", verbose_name=_('message'))
    identifier = InternalIdentifierField(unique=False)
    priority = EnumIntegerField(Priority, default=Priority.NORMAL, db_index=True, verbose_name=_('priority'))
    _data = JSONField(blank=True, null=True, editable=False, db_column="data")

    marked_read = models.BooleanField(db_index=True, editable=False, default=False, verbose_name=_('marked read'))
    marked_read_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, blank=True, null=True, editable=False, related_name="+", on_delete=models.SET_NULL,
        verbose_name=_('marked read by')
    )
    marked_read_on = models.DateTimeField(null=True, blank=True, verbose_name=_('marked read on'))

    objects = NotificationManager()

    def __init__(self, *args, **kwargs):
        url = kwargs.pop("url", None)
        super(Notification, self).__init__(*args, **kwargs)
        if url:
            self.url = url

    def save(self, *args, **kwargs):
        if self.recipient_type == RecipientType.SPECIFIC_USER and not self.recipient_id:
            raise ValueError("With RecipientType.SPECIFIC_USER, recipient is required")
        super(Notification, self).save(*args, **kwargs)

    def mark_read(self, user):
        if self.marked_read:
            return False
        self.marked_read = True
        self.marked_read_by = user
        self.marked_read_on = now()
        self.save(update_fields=('marked_read', 'marked_read_by', 'marked_read_on'))
        return True

    @property
    def is_read(self):
        return self.marked_read

    @property
    def data(self):
        if not self._data:
            self._data = {}
        return self._data

    @property
    def url(self):
        url = self.data.get("_url")
        if isinstance(url, dict):
            return reverse(**url)
        return url

    @url.setter
    def url(self, value):
        if self.pk:
            raise ValueError("URL can't be set on a saved notification")
        self.data["_url"] = value

    def set_reverse_url(self, **reverse_kwargs):
        if self.pk:
            raise ValueError("URL can't be set on a saved notification")

        try:
            reverse(**reverse_kwargs)
        except NoReverseMatch:  # pragma: no cover
            raise ValueError("Invalid reverse URL parameters")

        self.data["_url"] = reverse_kwargs
class SignpostPlan(
    DecimalValueFromDeviceTypeMixin,
    UpdatePlanLocationMixin,
    SourceControlModel,
    SoftDeleteModel,
    UserControlModel,
):
    id = models.UUIDField(
        primary_key=True, unique=True, editable=False, default=uuid.uuid4
    )
    location = models.PointField(_("Location (3D)"), dim=3, srid=settings.SRID)
    height = models.DecimalField(
        _("Height"), max_digits=5, decimal_places=2, blank=True, null=True
    )
    direction = models.IntegerField(_("Direction"), default=0)
    device_type = models.ForeignKey(
        TrafficControlDeviceType,
        verbose_name=_("Device type"),
        on_delete=models.PROTECT,
        limit_choices_to=Q(
            Q(target_model=None) | Q(target_model=DeviceTypeTargetModel.SIGNPOST)
        ),
    )
    value = models.DecimalField(
        _("Signpost value"), max_digits=10, decimal_places=2, blank=True, null=True
    )
    txt = models.CharField(_("Signpost txt"), max_length=254, blank=True, null=True)
    parent = models.ForeignKey(
        "self",
        verbose_name=_("Parent Signpost Plan"),
        on_delete=models.PROTECT,
        blank=True,
        null=True,
    )
    mount_plan = models.ForeignKey(
        MountPlan,
        verbose_name=_("Mount Plan"),
        on_delete=models.PROTECT,
        blank=True,
        null=True,
    )
    mount_type = models.ForeignKey(
        MountType,
        verbose_name=_("Mount type"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )
    validity_period_start = models.DateField(
        _("Validity period start"), blank=True, null=True
    )
    validity_period_end = models.DateField(
        _("Validity period end"), blank=True, null=True
    )
    plan = models.ForeignKey(
        Plan,
        verbose_name=_("Plan"),
        on_delete=models.PROTECT,
        related_name="signpost_plans",
        blank=True,
        null=True,
    )
    owner = models.ForeignKey(
        "traffic_control.Owner",
        verbose_name=_("Owner"),
        blank=False,
        null=False,
        on_delete=models.PROTECT,
    )
    size = EnumField(
        Size,
        verbose_name=_("Size"),
        max_length=1,
        default=Size.MEDIUM,
        blank=True,
        null=True,
    )
    reflection_class = EnumField(
        Reflection,
        verbose_name=_("Reflection"),
        max_length=2,
        default=Reflection.R1,
        blank=True,
        null=True,
    )
    seasonal_validity_period_start = models.DateField(
        _("Seasonal validity period start"), blank=True, null=True
    )
    seasonal_validity_period_end = models.DateField(
        _("Seasonal validity period end"), blank=True, null=True
    )
    attachment_class = models.CharField(
        _("Attachment class"), max_length=254, blank=True, null=True
    )
    target_id = models.CharField(_("Target ID"), max_length=254, blank=True, null=True)
    target_txt = models.CharField(
        _("Target txt"), max_length=254, blank=True, null=True
    )
    responsible_entity = models.CharField(
        _("Responsible entity"), max_length=254, blank=True, null=True
    )
    electric_maintainer = models.CharField(
        _("Electric maintainer"), max_length=254, blank=True, null=True
    )
    lifecycle = EnumIntegerField(
        Lifecycle, verbose_name=_("Lifecycle"), default=Lifecycle.ACTIVE
    )
    road_name = models.CharField(_("Road name"), max_length=254, blank=True, null=True)
    lane_number = EnumField(
        LaneNumber, verbose_name=_("Lane number"), default=LaneNumber.MAIN_1, blank=True
    )
    lane_type = EnumField(
        LaneType,
        verbose_name=_("Lane type"),
        default=LaneType.MAIN,
        blank=True,
    )
    location_specifier = EnumIntegerField(
        LocationSpecifier,
        verbose_name=_("Location specifier"),
        default=LocationSpecifier.RIGHT,
        blank=True,
        null=True,
    )

    objects = SoftDeleteQuerySet.as_manager()

    class Meta:
        db_table = "signpost_plan"
        verbose_name = _("Signpost Plan")
        verbose_name_plural = _("Signpost Plans")
        unique_together = ["source_name", "source_id"]

    def __str__(self):
        return f"{self.id} {self.device_type} {self.txt}"

    def save(self, *args, **kwargs):
        if not self.device_type.validate_relation(DeviceTypeTargetModel.SIGNPOST):
            raise ValidationError(
                f'Device type "{self.device_type}" is not allowed for signposts'
            )

        super().save(*args, **kwargs)
예제 #12
0
class AbstractOrderLine(MoneyPropped, models.Model, Priceful):
    product = UnsavedForeignKey(
        "shuup.Product", blank=True, null=True, related_name="order_lines",
        on_delete=models.PROTECT, verbose_name=_('product')
    )
    supplier = UnsavedForeignKey(
        "shuup.Supplier", blank=True, null=True, related_name="order_lines",
        on_delete=models.PROTECT, verbose_name=_('supplier')
    )

    parent_line = UnsavedForeignKey(
        "self", related_name="child_lines", blank=True, null=True,
        on_delete=models.PROTECT, verbose_name=_('parent line')
    )
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'))
    type = EnumIntegerField(OrderLineType, default=OrderLineType.PRODUCT, verbose_name=_('line type'))
    sku = models.CharField(max_length=48, blank=True, verbose_name=_('line SKU'))
    text = models.CharField(max_length=256, verbose_name=_('line text'))
    accounting_identifier = models.CharField(max_length=32, blank=True, verbose_name=_('accounting identifier'))
    require_verification = models.BooleanField(default=False, verbose_name=_('require verification'))
    verified = models.BooleanField(default=False, verbose_name=_('verified'))
    extra_data = JSONField(blank=True, null=True, verbose_name=_('extra data'))
    labels = models.ManyToManyField("Label", blank=True, verbose_name=_("labels"))

    # The following fields govern calculation of the prices
    quantity = QuantityField(verbose_name=_('quantity'), default=1)
    base_unit_price = PriceProperty('base_unit_price_value', 'order.currency', 'order.prices_include_tax')
    discount_amount = PriceProperty('discount_amount_value', 'order.currency', 'order.prices_include_tax')

    base_unit_price_value = MoneyValueField(verbose_name=_('unit price amount (undiscounted)'), default=0)
    discount_amount_value = MoneyValueField(verbose_name=_('total amount of discount'), default=0)

    created_on = models.DateTimeField(auto_now_add=True, editable=False, db_index=True, verbose_name=_('created on'))
    modified_on = models.DateTimeField(
        default=timezone.now, editable=False, db_index=True, verbose_name=_('modified on')
    )

    objects = OrderLineManager()

    class Meta:
        verbose_name = _('order line')
        verbose_name_plural = _('order lines')
        abstract = True

    def __str__(self):
        return "%dx %s (%s)" % (self.quantity, self.text, self.get_type_display())

    @property
    def tax_amount(self):
        """
        :rtype: shuup.utils.money.Money
        """
        zero = Money(0, self.order.currency)
        return sum((x.amount for x in self.taxes.all()), zero)

    @property
    def max_refundable_amount(self):
        """
        :rtype: shuup.utils.money.Money
        """
        refunds = self.child_lines.refunds().filter(parent_line=self)
        refund_total_value = sum(refund.taxful_price.amount.value for refund in refunds)
        return (self.taxful_price.amount + Money(refund_total_value, self.order.currency))

    @property
    def max_refundable_quantity(self):
        if self.type == OrderLineType.REFUND:
            return 0
        return self.quantity - self.refunded_quantity

    @property
    def refunded_quantity(self):
        return (
            self.child_lines.filter(type=OrderLineType.REFUND).aggregate(total=Sum("quantity"))["total"] or 0
        )

    @property
    def shipped_quantity(self):
        if not self.product:
            return 0
        return ShipmentProduct.objects.filter(
            shipment__supplier=self.supplier.id,
            product_id=self.product.id,
            shipment__order=self.order
        ).aggregate(total=Sum("quantity"))["total"] or 0

    def save(self, *args, **kwargs):
        if not self.sku:
            self.sku = u""
        if self.type == OrderLineType.PRODUCT and not self.product_id:
            raise ValidationError("Error! Product-type order line can not be saved without a set product.")

        if self.product_id and self.type != OrderLineType.PRODUCT:
            raise ValidationError("Error! Order line has product but is not of Product-type.")

        if self.product_id and not self.supplier_id:
            raise ValidationError("Error! Order line has product, but no supplier.")

        super(AbstractOrderLine, self).save(*args, **kwargs)
        if self.product_id:
            self.supplier.module.update_stock(self.product_id)
예제 #13
0
class Order(MoneyPropped, models.Model):
    # Identification
    shop = UnsavedForeignKey("Shop",
                             on_delete=models.PROTECT,
                             verbose_name=_("shop"))
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      db_index=True,
                                      verbose_name=_("created on"))
    modified_on = models.DateTimeField(auto_now=True,
                                       editable=False,
                                       db_index=True,
                                       verbose_name=_("modified on"))
    identifier = InternalIdentifierField(unique=True,
                                         db_index=True,
                                         verbose_name=_("order identifier"))
    # TODO: label is actually a choice field, need to check migrations/choice deconstruction
    label = models.CharField(max_length=32,
                             db_index=True,
                             verbose_name=_("label"))
    # The key shouldn't be possible to deduce (i.e. it should be random), but it is
    # not a secret. (It could, however, be used as key material for an actual secret.)
    key = models.CharField(max_length=32,
                           unique=True,
                           blank=False,
                           verbose_name=_("key"))
    reference_number = models.CharField(max_length=64,
                                        db_index=True,
                                        unique=True,
                                        blank=True,
                                        null=True,
                                        verbose_name=_("reference number"))

    # Contact information
    customer = UnsavedForeignKey(
        "Contact",
        related_name="customer_orders",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("customer"),
    )
    orderer = UnsavedForeignKey(
        "PersonContact",
        related_name="orderer_orders",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("orderer"),
    )
    billing_address = models.ForeignKey(
        "ImmutableAddress",
        related_name="billing_orders",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("billing address"),
    )
    shipping_address = models.ForeignKey(
        "ImmutableAddress",
        related_name="shipping_orders",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("shipping address"),
    )
    tax_number = models.CharField(max_length=64,
                                  blank=True,
                                  verbose_name=_("tax number"))
    phone = models.CharField(max_length=64,
                             blank=True,
                             verbose_name=_("phone"))
    email = models.EmailField(max_length=128,
                              blank=True,
                              verbose_name=_("email address"))

    # Customer related information that might change after order, but is important
    # for accounting and/or reports later.
    account_manager = models.ForeignKey("PersonContact",
                                        blank=True,
                                        null=True,
                                        on_delete=models.PROTECT,
                                        verbose_name=_("account manager"))
    customer_groups = models.ManyToManyField(
        "ContactGroup",
        related_name="customer_group_orders",
        verbose_name=_("customer groups"),
        blank=True)
    tax_group = models.ForeignKey("CustomerTaxGroup",
                                  blank=True,
                                  null=True,
                                  on_delete=models.PROTECT,
                                  verbose_name=_("tax group"))

    # Status
    creator = UnsavedForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="orders_created",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("creating user"),
    )
    modified_by = UnsavedForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="orders_modified",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("modifier user"),
    )
    deleted = models.BooleanField(db_index=True,
                                  default=False,
                                  verbose_name=_("deleted"))
    status = UnsavedForeignKey("OrderStatus",
                               verbose_name=_("status"),
                               on_delete=models.PROTECT)
    payment_status = EnumIntegerField(PaymentStatus,
                                      db_index=True,
                                      default=PaymentStatus.NOT_PAID,
                                      verbose_name=_("payment status"))
    shipping_status = EnumIntegerField(ShippingStatus,
                                       db_index=True,
                                       default=ShippingStatus.NOT_SHIPPED,
                                       verbose_name=_("shipping status"))

    # Methods
    payment_method = UnsavedForeignKey(
        "PaymentMethod",
        related_name="payment_orders",
        blank=True,
        null=True,
        default=None,
        on_delete=models.PROTECT,
        verbose_name=_("payment method"),
    )
    payment_method_name = models.CharField(
        max_length=100,
        blank=True,
        default="",
        verbose_name=_("payment method name"))
    payment_data = JSONField(blank=True,
                             null=True,
                             verbose_name=_("payment data"))

    shipping_method = UnsavedForeignKey(
        "ShippingMethod",
        related_name="shipping_orders",
        blank=True,
        null=True,
        default=None,
        on_delete=models.PROTECT,
        verbose_name=_("shipping method"),
    )
    shipping_method_name = models.CharField(
        max_length=100,
        blank=True,
        default="",
        verbose_name=_("shipping method name"))
    shipping_data = JSONField(blank=True,
                              null=True,
                              verbose_name=_("shipping data"))

    extra_data = JSONField(blank=True, null=True, verbose_name=_("extra data"))

    # Money stuff
    taxful_total_price = TaxfulPriceProperty("taxful_total_price_value",
                                             "currency")
    taxless_total_price = TaxlessPriceProperty("taxless_total_price_value",
                                               "currency")

    taxful_total_price_value = MoneyValueField(editable=False,
                                               verbose_name=_("grand total"),
                                               default=0)
    taxless_total_price_value = MoneyValueField(
        editable=False, verbose_name=_("taxless total"), default=0)
    currency = CurrencyField(verbose_name=_("currency"))
    prices_include_tax = models.BooleanField(
        verbose_name=_("prices include tax"))

    display_currency = CurrencyField(blank=True,
                                     verbose_name=_("display currency"))
    display_currency_rate = models.DecimalField(
        max_digits=36,
        decimal_places=9,
        default=1,
        verbose_name=_("display currency rate"))

    # Other
    ip_address = models.GenericIPAddressField(null=True,
                                              blank=True,
                                              verbose_name=_("IP address"))
    # `order_date` is not `auto_now_add` for backdating purposes
    order_date = models.DateTimeField(editable=False,
                                      db_index=True,
                                      verbose_name=_("order date"))
    payment_date = models.DateTimeField(null=True,
                                        editable=False,
                                        verbose_name=_("payment date"))

    language = LanguageField(blank=True, verbose_name=_("language"))
    customer_comment = models.TextField(blank=True,
                                        verbose_name=_("customer comment"))
    admin_comment = models.TextField(blank=True,
                                     verbose_name=_("admin comment/notes"))
    require_verification = models.BooleanField(
        default=False, verbose_name=_("requires verification"))
    all_verified = models.BooleanField(default=False,
                                       verbose_name=_("all lines verified"))
    marketing_permission = models.BooleanField(
        default=False, verbose_name=_("marketing permission"))
    _codes = JSONField(blank=True, null=True, verbose_name=_("codes"))

    common_select_related = ("billing_address", )
    objects = OrderQuerySet.as_manager()

    class Meta:
        ordering = ("-id", )
        verbose_name = _("order")
        verbose_name_plural = _("orders")

    def __str__(self):  # pragma: no cover
        if self.billing_address_id:
            name = self.billing_address.name
        else:
            name = "-"
        if ShuupSettings.get_setting("SHUUP_ENABLE_MULTIPLE_SHOPS"):
            return "Order %s (%s, %s)" % (self.identifier, self.shop.name,
                                          name)
        else:
            return "Order %s (%s)" % (self.identifier, name)

    @property
    def codes(self):
        return list(self._codes or [])

    @codes.setter
    def codes(self, value):
        codes = []
        for code in value:
            if not isinstance(code, six.text_type):
                raise TypeError("Error! `codes` must be a list of strings.")
            codes.append(code)
        self._codes = codes

    def cache_prices(self):
        taxful_total = TaxfulPrice(0, self.currency)
        taxless_total = TaxlessPrice(0, self.currency)
        for line in self.lines.all().prefetch_related("taxes"):
            taxful_total += line.taxful_price
            taxless_total += line.taxless_price
        self.taxful_total_price = taxful_total
        self.taxless_total_price = taxless_total

    def _cache_contact_values(self):
        sources = [
            self.shipping_address,
            self.billing_address,
            self.customer,
            self.orderer,
        ]

        fields = ("tax_number", "email", "phone")

        for field in fields:
            if getattr(self, field, None):
                continue
            for source in sources:
                val = getattr(source, field, None)
                if val:
                    setattr(self, field, val)
                    break

        if not self.id and self.customer:
            # These fields are used for reporting and should not
            # change after create even if empty at the moment of ordering.
            self.account_manager = getattr(self.customer, "account_manager",
                                           None)
            self.tax_group = self.customer.tax_group

    def _cache_contact_values_post_create(self):
        if self.customer:
            # These fields are used for reporting and should not
            # change after create even if empty at the  moment of ordering.
            self.customer_groups.set(self.customer.groups.all())

    def _cache_values(self):
        self._cache_contact_values()

        if not self.label:
            self.label = settings.SHUUP_DEFAULT_ORDER_LABEL

        if not self.currency:
            self.currency = self.shop.currency

        if not self.prices_include_tax:
            self.prices_include_tax = self.shop.prices_include_tax

        if not self.display_currency:
            self.display_currency = self.currency
            self.display_currency_rate = 1

        if self.shipping_method_id and not self.shipping_method_name:
            self.shipping_method_name = self.shipping_method.safe_translation_getter(
                "name",
                default=self.shipping_method.identifier,
                any_language=True)

        if self.payment_method_id and not self.payment_method_name:
            self.payment_method_name = self.payment_method.safe_translation_getter(
                "name",
                default=self.payment_method.identifier,
                any_language=True)

        if not self.key:
            self.key = get_random_string(32)

        if not self.modified_by:
            self.modified_by = self.creator

    def _save_identifiers(self):
        self.identifier = "%s" % (get_order_identifier(self))
        self.reference_number = get_reference_number(self)
        super(Order, self).save(update_fields=(
            "identifier",
            "reference_number",
        ))

    def full_clean(self, exclude=None, validate_unique=True):
        self._cache_values()
        return super(Order, self).full_clean(exclude, validate_unique)

    def save(self, *args, **kwargs):
        if not self.creator_id:
            if not settings.SHUUP_ALLOW_ANONYMOUS_ORDERS:
                raise ValidationError(
                    "Error! Anonymous (userless) orders are not allowed "
                    "when `SHUUP_ALLOW_ANONYMOUS_ORDERS` is not enabled.")
        self._cache_values()
        first_save = not self.pk
        old_status = self.status

        if not first_save:
            old_status = Order.objects.only("status").get(pk=self.pk).status

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

        if first_save:  # Have to do a double save the first time around to be able to save identifiers
            self._save_identifiers()
            self._cache_contact_values_post_create()

        order_changed.send(type(self), order=self)

        if self.status != old_status:
            order_status_changed.send(type(self),
                                      order=self,
                                      old_status=old_status,
                                      new_status=self.status)

    def delete(self, using=None):
        if not self.deleted:
            self.deleted = True
            self.add_log_entry("Success! Deleted (soft).",
                               kind=LogEntryKind.DELETION)
            # Bypassing local `save()` on purpose.
            super(Order, self).save(update_fields=("deleted", ), using=using)

    def set_canceled(self):
        if self.status.role != OrderStatusRole.CANCELED:
            self.status = OrderStatus.objects.get_default_canceled()
            self.save()

    def _set_paid(self):
        if self.payment_status != PaymentStatus.FULLY_PAID:  # pragma: no branch
            self.add_log_entry(_("Order was marked as paid."))
            self.payment_status = PaymentStatus.FULLY_PAID
            self.payment_date = local_now()
            self.save()

    def _set_partially_paid(self):
        if self.payment_status != PaymentStatus.PARTIALLY_PAID:
            self.add_log_entry(_("Order was marked as partially paid."))
            self.payment_status = PaymentStatus.PARTIALLY_PAID
            self.save()

    def is_paid(self):
        return self.payment_status == PaymentStatus.FULLY_PAID

    def is_partially_paid(self):
        return self.payment_status == PaymentStatus.PARTIALLY_PAID

    def is_deferred(self):
        return self.payment_status == PaymentStatus.DEFERRED

    def is_not_paid(self):
        return self.payment_status == PaymentStatus.NOT_PAID

    def get_total_paid_amount(self):
        amounts = self.payments.values_list("amount_value", flat=True)
        return Money(sum(amounts, Decimal(0)), self.currency)

    def get_total_unpaid_amount(self):
        difference = self.taxful_total_price.amount - self.get_total_paid_amount(
        )
        return max(difference, Money(0, self.currency))

    def can_create_payment(self):
        zero = Money(0, self.currency)
        return not (self.is_paid() or self.is_canceled()
                    ) and self.get_total_unpaid_amount() > zero

    def create_payment(self, amount, payment_identifier=None, description=""):
        """
        Create a payment with a given amount for this order.

        If the order already has payments and sum of their amounts is
        equal or greater than `self.taxful_total_price` and the order is not
        a zero price order, an exception is raised.

        If the end sum of all payments is equal or greater than
        `self.taxful_total_price`, then the order is marked as paid.

        :param amount:
          Amount of the payment to be created.
        :type amount: Money
        :param payment_identifier:
          Identifier of the created payment. If not set, default value
          of `gateway_id:order_id:number` will be used (where `number` is
          a number of payments in the order).
        :type payment_identifier: str|None
        :param description:
          Description of the payment. Will be set to `method` property
          of the created payment.
        :type description: str

        :returns: The created Payment object
        :rtype: shuup.core.models.Payment
        """
        assert isinstance(amount, Money)
        assert amount.currency == self.currency

        payments = self.payments.order_by("created_on")

        total_paid_amount = self.get_total_paid_amount()
        if total_paid_amount >= self.taxful_total_price.amount and self.taxful_total_price:
            raise NoPaymentToCreateException(
                "Error! Order %s has already been fully paid (%s >= %s)." %
                (self.pk, total_paid_amount, self.taxful_total_price))

        if not payment_identifier:
            number = payments.count() + 1
            payment_identifier = "%d:%d" % (self.id, number)

        payment = self.payments.create(
            payment_identifier=payment_identifier,
            amount_value=amount.value,
            description=description,
        )

        if self.get_total_paid_amount() >= self.taxful_total_price.amount:
            self._set_paid()  # also calls save
        else:
            self._set_partially_paid()

        payment_created.send(sender=type(self), order=self, payment=payment)
        return payment

    def can_create_shipment(self):
        return self.get_unshipped_products(
        ) and not self.is_canceled() and self.shipping_address

    # TODO: Rethink either the usage of shipment parameter or renaming the method for 2.0
    @atomic
    def create_shipment(self,
                        product_quantities,
                        supplier=None,
                        shipment=None):
        """
        Create a shipment for this order from `product_quantities`.
        `product_quantities` is expected to be a dict, which maps Product instances to quantities.

        Only quantities over 0 are taken into account, and if the mapping is empty or has no quantity value
        over 0, `NoProductsToShipException` will be raised.

        Orders without a shipping address defined, will raise `NoShippingAddressException`.

        :param product_quantities: a dict mapping Product instances to quantities to ship.
        :type product_quantities: dict[shuup.shop.models.Product, decimal.Decimal]
        :param supplier: Optional Supplier for this product. No validation is made.
        :param shipment: Optional unsaved Shipment for ShipmentProduct's. If not given
                         Shipment is created based on supplier parameter.
        :raises: NoProductsToShipException, NoShippingAddressException
        :return: Saved, complete Shipment object.
        :rtype: shuup.core.models.Shipment
        """
        if not product_quantities or not any(
                quantity > 0 for quantity in product_quantities.values()):
            raise NoProductsToShipException(
                "Error! No products to ship (`quantities` is empty or has no quantity over 0)."
            )

        if self.shipping_address is None:
            raise NoShippingAddressException(
                "Error! Shipping address is not defined for this order.")

        assert supplier or shipment
        if shipment:
            assert shipment.order == self
        else:
            from ._shipments import Shipment

            shipment = Shipment(order=self, supplier=supplier)
        shipment.save()

        if not supplier:
            supplier = shipment.supplier

        supplier.module.ship_products(shipment, product_quantities)

        self.add_log_entry(
            _("Success! Shipment #%d was created.") % shipment.id)
        self.update_shipping_status()
        shipment_created.send(sender=type(self), order=self, shipment=shipment)
        shipment_created_and_processed.send(sender=type(self),
                                            order=self,
                                            shipment=shipment)
        return shipment

    def can_create_refund(self, supplier=None):
        unrefunded_amount = self.get_total_unrefunded_amount(supplier)
        unrefunded_quantity = self.get_total_unrefunded_quantity(supplier)
        return ((unrefunded_amount.value > 0 or unrefunded_quantity > 0)
                and not self.is_canceled()
                and (self.payment_status
                     not in (PaymentStatus.NOT_PAID, PaymentStatus.CANCELED)))

    @atomic
    def create_refund(self, refund_data, created_by=None, supplier=None):
        """
        Create a refund if passed a list of refund line data.

        Refund line data is simply a list of dictionaries where
        each dictionary contains data for a particular refund line.

        Additionally, if the parent line is of `enum` type
        `OrderLineType.PRODUCT` and the `restock_products` boolean
        flag is set to `True`, the products will be restocked with the
        exact amount set in the order supplier's `quantity` field.

        :param refund_data: List of dicts containing refund data.
        :type refund_data: [dict]
        :param created_by: Refund creator's user instance, used for
                           adjusting supplier stock.
        :type created_by: django.contrib.auth.User|None
        """
        tax_module = taxing.get_tax_module()
        refund_lines = tax_module.create_refund_lines(self, supplier,
                                                      created_by, refund_data)

        self.cache_prices()
        self.save()
        self.update_shipping_status()
        self.update_payment_status()
        refund_created.send(sender=type(self),
                            order=self,
                            refund_lines=refund_lines)

    def create_full_refund(self, restock_products=False, created_by=None):
        """
        Create a full refund for entire order content, with the option of
        restocking stocked products.

        :param restock_products: Boolean indicating whether to also restock the products.
        :param created_by: Refund creator's user instance, used for
                           adjusting supplier stock.
        :type restock_products: bool|False
        """
        if self.has_refunds():
            raise NoRefundToCreateException
        self.cache_prices()
        line_data = [{
            "line": line,
            "quantity": line.quantity,
            "amount": line.taxful_price.amount,
            "restock_products": restock_products,
        } for line in self.lines.filter(quantity__gt=0)
                     if line.type != OrderLineType.REFUND]
        self.create_refund(line_data, created_by)

    def get_total_refunded_amount(self, supplier=None):
        refunds = self.lines.refunds()
        if supplier:
            refunds = refunds.filter(
                Q(parent_line__supplier=supplier) | Q(supplier=supplier))
        total = sum([line.taxful_price.amount.value for line in refunds])
        return Money(-total, self.currency)

    def get_total_unrefunded_amount(self, supplier=None):
        if supplier:
            total_refund_amount = sum([
                line.max_refundable_amount.value for line in self.lines.filter(
                    supplier=supplier).exclude(type=OrderLineType.REFUND)
            ])
            arbitrary_refunds = abs(
                sum([
                    refund_line.taxful_price.value for refund_line in
                    self.lines.filter(supplier=supplier,
                                      parent_line__isnull=True,
                                      type=OrderLineType.REFUND)
                ]))
            return (Money(max(total_refund_amount -
                              arbitrary_refunds, 0), self.currency)
                    if total_refund_amount else Money(0, self.currency))
        return max(self.taxful_total_price.amount, Money(0, self.currency))

    def get_total_unrefunded_quantity(self, supplier=None):
        queryset = self.lines.all()
        if supplier:
            queryset = queryset.filter(supplier=supplier)
        return sum([line.max_refundable_quantity for line in queryset])

    def get_total_tax_amount(self):
        return sum((line.tax_amount for line in self.lines.all()),
                   Money(0, self.currency))

    def has_refunds(self):
        return self.lines.refunds().exists()

    def create_shipment_of_all_products(self, supplier=None):
        """
        Create a shipment of all the products in this Order, no matter whether or
        not any have been previously marked as shipped or not.

        See the documentation for `create_shipment`.

        :param supplier: The Supplier to use. If `None`, the first supplier in
                         the order is used. (If several are in the order, this fails.)
        :return: Saved, complete Shipment object.
        :rtype: shuup.shop.models.Shipment
        """
        from ._products import ShippingMode

        suppliers_to_product_quantities = defaultdict(
            lambda: defaultdict(lambda: 0))
        lines = self.lines.filter(
            type=OrderLineType.PRODUCT,
            product__shipping_mode=ShippingMode.SHIPPED).values_list(
                "supplier_id", "product_id", "quantity")
        for supplier_id, product_id, quantity in lines:
            if product_id:
                suppliers_to_product_quantities[supplier_id][
                    product_id] += quantity

        if not suppliers_to_product_quantities:
            raise NoProductsToShipException(
                "Error! Could not find any products to ship.")

        if supplier is None:
            if len(suppliers_to_product_quantities) > 1:  # pragma: no cover
                raise ValueError(
                    "Error! `create_shipment_of_all_products` can be used only when there is a single supplier."
                )
            supplier_id, quantities = suppliers_to_product_quantities.popitem()
            supplier = Supplier.objects.get(pk=supplier_id)
        else:
            quantities = suppliers_to_product_quantities[supplier.id]

        products = dict(
            (product.pk, product)
            for product in Product.objects.filter(pk__in=quantities.keys()))
        quantities = dict((products[product_id], quantity)
                          for (product_id, quantity) in quantities.items())
        return self.create_shipment(quantities, supplier=supplier)

    def check_all_verified(self):
        if not self.all_verified:
            new_all_verified = not self.lines.filter(verified=False).exists()
            if new_all_verified:
                self.all_verified = True
                if self.require_verification:
                    self.add_log_entry(
                        _("All rows requiring verification have been verified."
                          ))
                    self.require_verification = False
                self.save()
        return self.all_verified

    def get_purchased_attachments(self):
        from ._product_media import ProductMedia

        if self.payment_status != PaymentStatus.FULLY_PAID:
            return ProductMedia.objects.none()
        prods = self.lines.exclude(product=None).values_list("product_id",
                                                             flat=True)
        return ProductMedia.objects.filter(product__in=prods,
                                           enabled=True,
                                           purchased=True)

    def get_tax_summary(self):
        """
        :rtype: taxing.TaxSummary
        """
        all_line_taxes = []
        untaxed = TaxlessPrice(0, self.currency)
        for line in self.lines.all():
            line_taxes = list(line.taxes.all())
            all_line_taxes.extend(line_taxes)
            if not line_taxes:
                untaxed += line.taxless_price
        return taxing.TaxSummary.from_line_taxes(all_line_taxes, untaxed)

    def get_product_ids_and_quantities(self, supplier=None):
        lines = self.lines.filter(type=OrderLineType.PRODUCT)
        if supplier:
            supplier_id = supplier if isinstance(
                supplier, six.integer_types) else supplier.pk
            lines = lines.filter(supplier_id=supplier_id)

        quantities = defaultdict(lambda: 0)
        for product_id, quantity in lines.values_list("product_id",
                                                      "quantity"):
            quantities[product_id] += quantity
        return dict(quantities)

    def has_products(self):
        return self.lines.products().exists()

    def has_products_requiring_shipment(self, supplier=None):
        from ._products import ShippingMode

        lines = self.lines.products().filter(
            product__shipping_mode=ShippingMode.SHIPPED)
        if supplier:
            supplier_id = supplier if isinstance(
                supplier, six.integer_types) else supplier.pk
            lines = lines.filter(supplier_id=supplier_id)
        return lines.exists()

    def is_complete(self):
        return self.status.role == OrderStatusRole.COMPLETE

    def can_set_complete(self):
        return not (self.is_complete() or self.is_canceled()
                    or bool(self.get_unshipped_products()))

    def is_fully_shipped(self):
        return self.shipping_status == ShippingStatus.FULLY_SHIPPED

    def is_partially_shipped(self):
        return self.shipping_status == ShippingStatus.PARTIALLY_SHIPPED

    def is_canceled(self):
        return self.status.role == OrderStatusRole.CANCELED

    def can_set_canceled(self):
        canceled = self.status.role == OrderStatusRole.CANCELED
        paid = self.is_paid()
        shipped = self.shipping_status != ShippingStatus.NOT_SHIPPED
        return not (canceled or paid or shipped)

    def update_shipping_status(self):
        status_before_update = self.shipping_status
        if not self.get_unshipped_products():
            self.shipping_status = ShippingStatus.FULLY_SHIPPED
        elif self.shipments.all_except_deleted().count():
            self.shipping_status = ShippingStatus.PARTIALLY_SHIPPED
        else:
            self.shipping_status = ShippingStatus.NOT_SHIPPED
        if status_before_update != self.shipping_status:
            self.add_log_entry(
                _("New shipping status is set to: %(shipping_status)s." %
                  {"shipping_status": self.shipping_status}))
            self.save(update_fields=("shipping_status", ))

    def update_payment_status(self):
        status_before_update = self.payment_status
        if self.get_total_unpaid_amount().value == 0:
            self.payment_status = PaymentStatus.FULLY_PAID
        elif self.get_total_paid_amount().value > 0:
            self.payment_status = PaymentStatus.PARTIALLY_PAID
        elif self.payment_status != PaymentStatus.DEFERRED:  # Do not make deferred here not paid
            self.payment_status = PaymentStatus.NOT_PAID
        if status_before_update != self.payment_status:
            self.add_log_entry(
                _("New payment status is set to: %(payment_status)s." %
                  {"payment_status": self.payment_status}))
            self.save(update_fields=("payment_status", ))

    def get_known_additional_data(self):
        """
        Get a list of "known additional data" in this order's `payment_data`, `shipping_data` and `extra_data`.
        The list is returned in the order the fields are specified in the settings entries for said known keys.
        `dict(that_list)` can of course be used to "flatten" the list into a dict.
        :return: list of 2-tuples.
        """
        output = []
        for data_dict, name_mapping in (
            (self.payment_data, settings.SHUUP_ORDER_KNOWN_PAYMENT_DATA_KEYS),
            (self.shipping_data,
             settings.SHUUP_ORDER_KNOWN_SHIPPING_DATA_KEYS),
            (self.extra_data, settings.SHUUP_ORDER_KNOWN_EXTRA_DATA_KEYS),
        ):
            if hasattr(data_dict, "get"):
                for key, display_name in name_mapping:
                    if key in data_dict:
                        output.append(
                            (force_text(display_name), data_dict[key]))
        return output

    def get_product_summary(self, supplier=None):
        """Return a dict of product IDs -> {ordered, unshipped, refunded, shipped, line_text, suppliers}"""
        supplier_id = (supplier if isinstance(supplier, six.integer_types) else
                       supplier.pk) if supplier else None

        products = defaultdict(lambda: defaultdict(lambda: Decimal(0)))

        def _append_suppliers_info(product_id, supplier):
            if not products[product_id]["suppliers"]:
                products[product_id]["suppliers"] = [supplier]
            elif supplier not in products[product_id]["suppliers"]:
                products[product_id]["suppliers"].append(supplier)

        # Quantity for all orders
        # Note! This contains all product lines so we do not need to worry
        # about suppliers after this.
        lines = self.lines.filter(type=OrderLineType.PRODUCT)
        if supplier_id:
            lines = lines.filter(supplier_id=supplier_id)

        lines_values = lines.values_list("product_id", "text", "quantity",
                                         "supplier__name")
        for product_id, line_text, quantity, supplier_name in lines_values:
            products[product_id]["line_text"] = line_text
            products[product_id]["ordered"] += quantity
            _append_suppliers_info(product_id, supplier_name)

        # Quantity to ship
        for product_id, quantity in self._get_to_ship_quantities(supplier_id):
            products[product_id]["unshipped"] += quantity

        # Quantity shipped
        for product_id, quantity in self._get_shipped_quantities(supplier_id):
            products[product_id]["shipped"] += quantity
            products[product_id]["unshipped"] -= quantity

        # Quantity refunded
        for product_id in self._get_refunded_product_ids(supplier_id):
            refunds = self.lines.refunds().filter(
                parent_line__product_id=product_id)
            refunded_quantity = refunds.aggregate(
                total=models.Sum("quantity"))["total"] or 0
            products[product_id]["refunded"] = refunded_quantity
            products[product_id]["unshipped"] = max(
                products[product_id]["unshipped"] - refunded_quantity, 0)

        return products

    def _get_to_ship_quantities(self, supplier_id):
        from ._products import ShippingMode

        lines_to_ship = self.lines.filter(
            type=OrderLineType.PRODUCT,
            product__shipping_mode=ShippingMode.SHIPPED)
        if supplier_id:
            lines_to_ship = lines_to_ship.filter(supplier_id=supplier_id)
        return lines_to_ship.values_list("product_id", "quantity")

    def _get_shipped_quantities(self, supplier_id):
        from ._shipments import ShipmentProduct, ShipmentStatus

        shipment_prods = ShipmentProduct.objects.filter(
            shipment__order=self).exclude(
                shipment__status=ShipmentStatus.DELETED)
        if supplier_id:
            shipment_prods = shipment_prods.filter(
                shipment__supplier_id=supplier_id)
        return shipment_prods.values_list("product_id", "quantity")

    def _get_refunded_product_ids(self, supplier_id):
        refunded_prods = self.lines.refunds().filter(
            type=OrderLineType.REFUND, parent_line__type=OrderLineType.PRODUCT)
        if supplier_id:
            refunded_prods = refunded_prods.filter(
                parent_line__supplier_id=supplier_id)
        return refunded_prods.distinct().values_list("parent_line__product_id",
                                                     flat=True)

    def get_unshipped_products(self, supplier=None):
        return dict((product, summary_datum)
                    for product, summary_datum in self.get_product_summary(
                        supplier=supplier).items()
                    if summary_datum["unshipped"])

    def get_status_display(self):
        return force_text(self.status)

    def get_payment_method_display(self):
        return force_text(self.payment_method_name)

    def get_shipping_method_display(self):
        return force_text(self.shipping_method_name)

    def get_tracking_codes(self):
        return [
            shipment.tracking_code
            for shipment in self.shipments.all_except_deleted()
            if shipment.tracking_code
        ]

    def get_sent_shipments(self):
        return self.shipments.all_except_deleted().sent()

    def can_edit(self):
        return (settings.SHUUP_ALLOW_EDITING_ORDER and not self.has_refunds()
                and not self.is_canceled() and not self.is_complete()
                and self.shipping_status == ShippingStatus.NOT_SHIPPED
                and self.payment_status == PaymentStatus.NOT_PAID)

    def get_customer_name(self):
        name_attrs = [
            "customer", "billing_address", "orderer", "shipping_address"
        ]
        for attr in name_attrs:
            if getattr(self, "%s_id" % attr):
                return getattr(self, attr).name

    def get_available_shipping_methods(self):
        """
        Get available shipping methods.

        :rtype: list[ShippingMethod]
        """
        from shuup.core.models import ShippingMethod

        product_ids = self.lines.products().values_list("id", flat=True)
        return [
            m for m in ShippingMethod.objects.available(shop=self.shop,
                                                        products=product_ids)
            if m.is_available_for(self)
        ]

    def get_available_payment_methods(self):
        """
        Get available payment methods.

        :rtype: list[PaymentMethod]
        """
        from shuup.core.models import PaymentMethod

        product_ids = self.lines.products().values_list("id", flat=True)
        return [
            m for m in PaymentMethod.objects.available(shop=self.shop,
                                                       products=product_ids)
            if m.is_available_for(self)
        ]
예제 #14
0
파일: orders.py 프로젝트: krisera/shoop
class Order(models.Model):
    # Identification
    shop = UnsavedForeignKey("Shop", on_delete=models.PROTECT)
    created_on = models.DateTimeField(auto_now_add=True, editable=False)
    identifier = InternalIdentifierField(unique=True,
                                         db_index=True,
                                         verbose_name=_('order identifier'))
    # TODO: label is actually a choice field, need to check migrations/choice deconstruction
    label = models.CharField(max_length=32,
                             db_index=True,
                             verbose_name=_('label'))
    # The key shouldn't be possible to deduce (i.e. it should be random), but it is
    # not a secret. (It could, however, be used as key material for an actual secret.)
    key = models.CharField(max_length=32,
                           unique=True,
                           blank=False,
                           verbose_name=_('key'))
    reference_number = models.CharField(max_length=64,
                                        db_index=True,
                                        unique=True,
                                        blank=True,
                                        null=True,
                                        verbose_name=_('reference number'))

    # Contact information
    customer = UnsavedForeignKey("Contact",
                                 related_name='customer_orders',
                                 blank=True,
                                 null=True,
                                 on_delete=models.PROTECT,
                                 verbose_name=_('customer'))
    orderer = UnsavedForeignKey("PersonContact",
                                related_name='orderer_orders',
                                blank=True,
                                null=True,
                                on_delete=models.PROTECT,
                                verbose_name=_('orderer'))
    billing_address = UnsavedForeignKey("Address",
                                        related_name="billing_orders",
                                        blank=True,
                                        null=True,
                                        on_delete=models.PROTECT,
                                        verbose_name=_('billing address'))
    shipping_address = UnsavedForeignKey("Address",
                                         related_name='shipping_orders',
                                         blank=True,
                                         null=True,
                                         on_delete=models.PROTECT,
                                         verbose_name=_('shipping address'))
    tax_number = models.CharField(max_length=20,
                                  blank=True,
                                  verbose_name=_('Tax number'))
    phone = models.CharField(max_length=32,
                             blank=True,
                             verbose_name=_('phone'))
    email = models.EmailField(max_length=128,
                              blank=True,
                              verbose_name=_('email address'))

    # Status
    creator = UnsavedForeignKey(settings.AUTH_USER_MODEL,
                                related_name='orders_created',
                                blank=True,
                                null=True,
                                on_delete=models.PROTECT,
                                verbose_name=_('creating user'))
    deleted = models.BooleanField(db_index=True, default=False)
    status = UnsavedForeignKey("OrderStatus",
                               verbose_name=_('status'),
                               on_delete=models.PROTECT)
    payment_status = EnumIntegerField(PaymentStatus,
                                      db_index=True,
                                      default=PaymentStatus.NOT_PAID,
                                      verbose_name=_('payment status'))
    shipping_status = EnumIntegerField(ShippingStatus,
                                       db_index=True,
                                       default=ShippingStatus.NOT_SHIPPED,
                                       verbose_name=_('shipping status'))

    # Methods
    payment_method = UnsavedForeignKey("PaymentMethod",
                                       related_name="payment_orders",
                                       blank=True,
                                       null=True,
                                       default=None,
                                       on_delete=models.PROTECT,
                                       verbose_name=_('payment method'))
    payment_method_name = models.CharField(
        max_length=64,
        blank=True,
        default="",
        verbose_name=_('payment method name'))
    payment_data = JSONField(blank=True, null=True)

    shipping_method = UnsavedForeignKey("ShippingMethod",
                                        related_name='shipping_orders',
                                        blank=True,
                                        null=True,
                                        default=None,
                                        on_delete=models.PROTECT,
                                        verbose_name=_('shipping method'))
    shipping_method_name = models.CharField(
        max_length=64,
        blank=True,
        default="",
        verbose_name=_('shipping method name'))
    shipping_data = JSONField(blank=True, null=True)

    extra_data = JSONField(blank=True, null=True)

    # Money stuff
    taxful_total_price = TaxfulPriceProperty('taxful_total_price_value',
                                             'currency')
    taxless_total_price = TaxlessPriceProperty('taxless_total_price_value',
                                               'currency')

    taxful_total_price_value = MoneyValueField(editable=False,
                                               verbose_name=_('grand total'),
                                               default=0)
    taxless_total_price_value = MoneyValueField(
        editable=False, verbose_name=_('taxless total'), default=0)
    currency = CurrencyField()
    prices_include_tax = models.BooleanField(
    )  # TODO: (TAX) Document Order.prices_include_tax

    display_currency = CurrencyField(blank=True)
    display_currency_rate = models.DecimalField(max_digits=36,
                                                decimal_places=9,
                                                default=1)

    # Other
    ip_address = models.GenericIPAddressField(null=True,
                                              blank=True,
                                              verbose_name=_('IP address'))
    # order_date is not `auto_now_add` for backdating purposes
    order_date = models.DateTimeField(editable=False,
                                      verbose_name=_('order date'))
    payment_date = models.DateTimeField(null=True,
                                        editable=False,
                                        verbose_name=_('payment date'))

    # TODO: (TAX) Add me? customer_tax_group = models.ForeignKey(CustomerTaxGroup, blank=True, null=True)
    language = LanguageField(blank=True, verbose_name=_('language'))
    customer_comment = models.TextField(blank=True,
                                        verbose_name=_('customer comment'))
    admin_comment = models.TextField(blank=True,
                                     verbose_name=_('admin comment/notes'))
    require_verification = models.BooleanField(
        default=False, verbose_name=_('requires verification'))
    all_verified = models.BooleanField(default=False,
                                       verbose_name=_('all lines verified'))
    marketing_permission = models.BooleanField(
        default=True, verbose_name=_('marketing permission'))

    common_select_related = ("billing_address", )
    objects = OrderQuerySet.as_manager()

    class Meta:
        ordering = ("-id", )
        verbose_name = _('order')
        verbose_name_plural = _('orders')

    def __str__(self):  # pragma: no cover
        if self.billing_address_id:
            name = self.billing_address.name
        else:
            name = "-"
        if settings.SHOOP_ENABLE_MULTIPLE_SHOPS:
            return "Order %s (%s, %s)" % (self.identifier, self.shop.name,
                                          name)
        else:
            return "Order %s (%s)" % (self.identifier, name)

    def cache_prices(self):
        taxful_total = TaxfulPrice(0, self.currency)
        taxless_total = TaxlessPrice(0, self.currency)
        for line in self.lines.all():
            taxful_total += line.taxful_total_price
            taxless_total += line.taxless_total_price
        self.taxful_total_price = _round_price(taxful_total)
        self.taxless_total_price = _round_price(taxless_total)

    def _cache_contact_values(self):
        sources = [
            self.shipping_address,
            self.billing_address,
            self.customer,
            self.orderer,
        ]

        fields = ("tax_number", "email", "phone")

        for field in fields:
            if getattr(self, field, None):
                continue
            for source in sources:
                val = getattr(source, field, None)
                if val:
                    setattr(self, field, val)
                    break

    def _cache_values(self):
        self._cache_contact_values()

        if not self.label:
            self.label = settings.SHOOP_DEFAULT_ORDER_LABEL

        if not self.currency:
            self.currency = self.shop.currency

        if not self.prices_include_tax:
            self.prices_include_tax = self.shop.prices_include_tax

        if not self.display_currency:
            self.display_currency = self.currency
            self.display_currency_rate = 1

        if self.shipping_method_id and not self.shipping_method_name:
            self.shipping_method_name = self.shipping_method.safe_translation_getter(
                "name",
                default=self.shipping_method.identifier,
                any_language=True)

        if self.payment_method_id and not self.payment_method_name:
            self.payment_method_name = self.payment_method.safe_translation_getter(
                "name",
                default=self.payment_method.identifier,
                any_language=True)

        if not self.key:
            self.key = get_random_string(32)

    def _save_identifiers(self):
        self.identifier = "%s" % (get_order_identifier(self))
        self.reference_number = get_reference_number(self)
        super(Order, self).save(update_fields=(
            "identifier",
            "reference_number",
        ))

    def full_clean(self, exclude=None, validate_unique=True):
        self._cache_values()
        return super(Order, self).full_clean(exclude, validate_unique)

    def create_immutable_address_copies(self):
        for field in ("billing_address", "shipping_address"):
            address = getattr(self, field, None)
            if address and not address.is_immutable:
                if address.pk:
                    address = address.copy()
                    address.set_immutable()
                else:
                    address.set_immutable()
                setattr(self, field, address)

    def save(self, *args, **kwargs):
        if not self.creator_id:
            if not settings.SHOOP_ALLOW_ANONYMOUS_ORDERS:
                raise ValidationError(
                    "Anonymous (userless) orders are not allowed "
                    "when SHOOP_ALLOW_ANONYMOUS_ORDERS is not enabled.")
        self._cache_values()
        first_save = (not self.pk)
        self.create_immutable_address_copies()
        super(Order, self).save(*args, **kwargs)
        if first_save:  # Have to do a double save the first time around to be able to save identifiers
            self._save_identifiers()

    def delete(self, using=None):
        if not self.deleted:
            self.deleted = True
            self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION)
            # Bypassing local `save()` on purpose.
            super(Order, self).save(update_fields=("deleted", ), using=using)

    def set_canceled(self):
        if self.status.role != OrderStatusRole.CANCELED:
            self.status = OrderStatus.objects.get_default_canceled()
            self.save()

    def _set_paid(self):
        if self.payment_status != PaymentStatus.FULLY_PAID:  # pragma: no branch
            self.add_log_entry(_('Order marked as paid.'))
            self.payment_status = PaymentStatus.FULLY_PAID
            self.payment_date = now()
            self.save()

    def is_paid(self):
        return (self.payment_status == PaymentStatus.FULLY_PAID)

    def get_total_paid_amount(self):
        amounts = self.payments.values_list('amount_value', flat=True)
        return Money(sum(amounts, Decimal(0)), self.currency)

    def create_payment(self, amount, payment_identifier=None, description=''):
        """
        Create a payment with given amount for this order.

        If the order already has payments and sum of their amounts is
        equal or greater than self.taxful_total_price, an exception is raised.

        If the end sum of all payments is equal or greater than
        self.taxful_total_price, then the order is marked as paid.

        :param amount:
          Amount of the payment to be created
        :type amount: Money
        :param payment_identifier:
          Identifier of the created payment. If not set, default value
          of "gateway_id:order_id:number" will be used (where number is
          number of payments in the order).
        :type payment_identifier: str|None
        :param description:
          Description of the payment. Will be set to `method` property
          of the created payment.
        :type description: str

        :returns: The created Payment object
        :rtype: shoop.core.models.Payment
        """
        assert isinstance(amount, Money)
        assert amount.currency == self.currency

        payments = self.payments.order_by('created_on')

        total_paid_amount = self.get_total_paid_amount()
        if total_paid_amount >= self.taxful_total_price.amount:
            raise NoPaymentToCreateException(
                "Order %s has already been fully paid (%s >= %s)." %
                (self.pk, total_paid_amount, self.taxful_total_price))

        if not payment_identifier:
            number = payments.count() + 1
            payment_identifier = '%d:%d' % (self.id, number)

        payment = self.payments.create(
            payment_identifier=payment_identifier,
            amount_value=amount.value,
            description=description,
        )

        if self.get_total_paid_amount() >= self.taxful_total_price.amount:
            self._set_paid()  # also calls save

        return payment

    def create_shipment(self, supplier, product_quantities):
        """
        Create a shipment for this order from `product_quantities`.
        `product_quantities` is expected to be a dict mapping Product instances to quantities.

        Only quantities over 0 are taken into account, and if the mapping is empty or has no quantity value
        over 0, `NoProductsToShipException` will be raised.

        :param supplier: The Supplier for this product. No validation is made
                         as to whether the given supplier supplies the products.
        :param product_quantities: a dict mapping Product instances to quantities to ship
        :type product_quantities: dict[shoop.shop.models.products.Product, decimal.Decimal]
        :raises: NoProductsToShipException
        :return: Saved, complete Shipment object
        :rtype: shoop.core.models.shipments.Shipment
        """
        if not product_quantities or not any(
                quantity > 0 for quantity in product_quantities.values()):
            raise NoProductsToShipException(
                "No products to ship (`quantities` is empty or has no quantity over 0)."
            )

        from .shipments import Shipment, ShipmentProduct

        shipment = Shipment(order=self, supplier=supplier)
        shipment.save()

        for product, quantity in product_quantities.items():
            if quantity > 0:
                sp = ShipmentProduct(shipment=shipment,
                                     product=product,
                                     quantity=quantity)
                sp.cache_values()
                sp.save()

        shipment.cache_values()
        shipment.save()

        self.add_log_entry(_(u"Shipment #%d created.") % shipment.id)
        self.check_and_set_fully_shipped()
        return shipment

    def create_shipment_of_all_products(self, supplier=None):
        """
        Create a shipment of all the products in this Order, no matter whether or not any have been previously
        marked as shipped or not.

        See the documentation for `create_shipment`.

        :param supplier: The Supplier to use. If `None`, the first supplier in
                         the order is used. (If several are in the order, this fails.)
        :return: Saved, complete Shipment object
        :rtype: shoop.shop.models.shipments.Shipment
        """
        suppliers_to_product_quantities = defaultdict(
            lambda: defaultdict(lambda: 0))
        lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list(
            "supplier_id", "product_id", "quantity"))
        for supplier_id, product_id, quantity in lines:
            if product_id:
                suppliers_to_product_quantities[supplier_id][
                    product_id] += quantity

        if not suppliers_to_product_quantities:
            raise NoProductsToShipException(
                "Could not find any products to ship.")

        if supplier is None:
            if len(suppliers_to_product_quantities) > 1:  # pragma: no cover
                raise ValueError(
                    "Can only use create_shipment_of_all_products when there is only one supplier"
                )
            supplier_id, quantities = suppliers_to_product_quantities.popitem()
            supplier = Supplier.objects.get(pk=supplier_id)
        else:
            quantities = suppliers_to_product_quantities[supplier.id]

        products = dict(
            (product.pk, product)
            for product in Product.objects.filter(pk__in=quantities.keys()))
        quantities = dict((products[product_id], quantity)
                          for (product_id, quantity) in quantities.items())
        return self.create_shipment(supplier, quantities)

    def check_all_verified(self):
        if not self.all_verified:
            new_all_verified = (not self.lines.filter(verified=False).exists())
            if new_all_verified:
                self.all_verified = True
                if self.require_verification:
                    self.add_log_entry(
                        _('All rows requiring verification have been verified.'
                          ))
                    self.require_verification = False
                self.save()
        return self.all_verified

    def get_purchased_attachments(self):
        from .product_media import ProductMedia

        if self.payment_status != PaymentStatus.FULLY_PAID:
            return ProductMedia.objects.none()
        prods = self.lines.exclude(product=None).values_list("product_id",
                                                             flat=True)
        return ProductMedia.objects.filter(product__in=prods,
                                           enabled=True,
                                           purchased=True)

    def get_tax_summary(self):
        """
        :rtype: taxing.TaxSummary
        """
        all_line_taxes = []
        untaxed = TaxlessPrice(0, self.currency)
        for line in self.lines.all():
            line_taxes = list(line.taxes.all())
            all_line_taxes.extend(line_taxes)
            if not line_taxes:
                untaxed += line.taxless_total_price
        return taxing.TaxSummary.from_line_taxes(all_line_taxes, untaxed)

    def get_product_ids_and_quantities(self):
        quantities = defaultdict(lambda: 0)
        for product_id, quantity in self.lines.filter(
                type=OrderLineType.PRODUCT).values_list(
                    "product_id", "quantity"):
            quantities[product_id] += quantity
        return dict(quantities)

    def is_complete(self):
        return (self.status.role == OrderStatusRole.COMPLETE)

    def can_set_complete(self):
        fully_shipped = (self.shipping_status == ShippingStatus.FULLY_SHIPPED)
        canceled = (self.status.role == OrderStatusRole.CANCELED)
        return (not self.is_complete()) and fully_shipped and (not canceled)

    def check_and_set_fully_shipped(self):
        if self.shipping_status != ShippingStatus.FULLY_SHIPPED:
            if not self.get_unshipped_products():
                self.shipping_status = ShippingStatus.FULLY_SHIPPED
                self.add_log_entry(
                    _(u"All products have been shipped. Fully Shipped status set."
                      ))
                self.save(update_fields=("shipping_status", ))
                return True

    def get_known_additional_data(self):
        """
        Get a list of "known additional data" in this order's payment_data, shipping_data and extra_data.
        The list is returned in the order the fields are specified in the settings entries for said known keys.
        `dict(that_list)` can of course be used to "flatten" the list into a dict.
        :return: list of 2-tuples.
        """
        output = []
        for data_dict, name_mapping in (
            (self.payment_data, settings.SHOOP_ORDER_KNOWN_PAYMENT_DATA_KEYS),
            (self.shipping_data,
             settings.SHOOP_ORDER_KNOWN_SHIPPING_DATA_KEYS),
            (self.extra_data, settings.SHOOP_ORDER_KNOWN_EXTRA_DATA_KEYS),
        ):
            if hasattr(data_dict, "get"):
                for key, display_name in name_mapping:
                    if key in data_dict:
                        output.append(
                            (force_text(display_name), data_dict[key]))
        return output

    def get_product_summary(self):
        """Return a dict of product IDs -> {ordered, unshipped, shipped}"""

        products = defaultdict(lambda: defaultdict(lambda: Decimal(0)))
        lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list(
            "product_id", "quantity"))
        for product_id, quantity in lines:
            products[product_id]['ordered'] += quantity
            products[product_id]['unshipped'] += quantity

        from .shipments import ShipmentProduct

        shipment_prods = (ShipmentProduct.objects.filter(
            shipment__order=self).values_list("product_id", "quantity"))
        for product_id, quantity in shipment_prods:
            products[product_id]['shipped'] += quantity
            products[product_id]['unshipped'] -= quantity

        return products

    def get_unshipped_products(self):
        return dict(
            (product, summary_datum)
            for product, summary_datum in self.get_product_summary().items()
            if summary_datum['unshipped'])

    def get_status_display(self):
        return force_text(self.status)
예제 #15
0
class Content(models.Model):
    # Local UUID
    uuid = models.UUIDField(unique=True, blank=True, null=True, editable=False)

    text = models.TextField(_("Text"), blank=True)

    # Federation GUID
    # Optional, related to Diaspora network platforms
    guid = models.CharField(_("GUID"),
                            max_length=255,
                            unique=True,
                            editable=False,
                            blank=True,
                            null=True)

    author = models.ForeignKey("users.Profile",
                               on_delete=models.CASCADE,
                               verbose_name=_("Author"))
    visibility = EnumIntegerField(Visibility,
                                  default=Visibility.PUBLIC,
                                  db_index=True)

    # Federation identifier
    # Optional
    fid = models.URLField(_("Federation ID"),
                          editable=False,
                          max_length=255,
                          unique=True,
                          blank=True,
                          null=True)

    # Is this content pinned to the user profile
    pinned = models.BooleanField(_("Pinned to profile"),
                                 default=False,
                                 db_index=True)
    # Order int to allow ordering content within some context, for example profile
    order = models.PositiveIntegerField(verbose_name=_("Order"),
                                        default=1,
                                        db_index=True)

    # For example mobile, server or application name
    service_label = models.CharField(_("Service label"),
                                     blank=True,
                                     max_length=32)

    # oEmbed or preview based on OG tags
    show_preview = models.BooleanField(
        _("Show OEmbed or OpenGraph preview"),
        default=True,
        help_text=_(
            "Disable to turn off fetching and showing an OEmbed or OpenGraph preview using the links in "
            "the text."),
    )
    oembed = models.ForeignKey(OEmbedCache,
                               verbose_name=_("OEmbed cache"),
                               on_delete=models.SET_NULL,
                               null=True)
    opengraph = models.ForeignKey(OpenGraphCache,
                                  verbose_name=_("OpenGraph cache"),
                                  on_delete=models.SET_NULL,
                                  null=True)

    mentions = models.ManyToManyField("users.Profile",
                                      verbose_name=_("Mentions"),
                                      related_name="mentioned_in")
    tags = models.ManyToManyField(Tag,
                                  verbose_name=_("Tags"),
                                  related_name="contents")

    parent = models.ForeignKey(
        "self",
        on_delete=models.CASCADE,
        verbose_name=_("Parent"),
        related_name="children",
        null=True,
        blank=True,
    )

    share_of = models.ForeignKey(
        "self",
        on_delete=models.CASCADE,
        verbose_name=_("Share of"),
        related_name="shares",
        null=True,
        blank=True,
    )

    federate = models.BooleanField(
        _("Federate to remote servers"),
        default=True,
        help_text=_(
            "Disable to skip federating this version to remote servers. Note, saved content version "
            "will still be updated to local streams."))

    # Fields relevant for Visibility.LIMITED only
    limited_visibilities = models.ManyToManyField(
        "users.Profile",
        verbose_name=_("Limitied visibilities"),
        related_name="limited_visibilities",
    )
    include_following = models.BooleanField(
        _("Include people I follow"),
        default=False,
        help_text=_(
            "Automatically includes all the people you follow as recipients."),
    )

    # Dates
    remote_created = models.DateTimeField(_("Remote created"),
                                          blank=True,
                                          null=True)
    created = AutoCreatedField(_('Created'), db_index=True)
    modified = AutoLastModifiedField(_('Modified'))

    # Cached data on save
    content_type = EnumIntegerField(ContentType,
                                    default=ContentType.CONTENT,
                                    db_index=True,
                                    editable=False)
    local = models.BooleanField(_("Local"), default=False, editable=False)
    rendered = models.TextField(_("Rendered text"), blank=True, editable=False)
    reply_count = models.PositiveIntegerField(_("Reply count"),
                                              default=0,
                                              editable=False)
    shares_count = models.PositiveIntegerField(_("Shares count"),
                                               default=0,
                                               editable=False)
    # Indirect parent in the hierarchy
    root_parent = models.ForeignKey(
        "self",
        on_delete=models.CASCADE,
        verbose_name=_("Root parent"),
        related_name="all_children",
        null=True,
        blank=True,
    )

    # Other relations
    activities = GenericRelation(Activity)

    objects = ContentManager()

    def __str__(self):
        return f"{truncatechars(self.text, 30)} ({self.content_type}, {self.visibility}, {self.fid or self.guid})"

    def cache_data(self, commit=False):
        """Calculate some extra data."""
        # Local
        self.local = self.author.user is not None
        if self.pk:
            # Reply count
            share_ids = Content.objects.filter(share_of=self).values_list(
                "id", flat=True)
            self.reply_count = self.all_children.count(
            ) + Content.objects.filter(parent_id__in=share_ids).count()
            # Share count
            self.shares_count = self.shares.count()
            if commit:
                Content.objects.filter(id=self.id).update(
                    local=self.local,
                    reply_count=self.reply_count,
                    shares_count=self.shares_count,
                )

    def cache_related_object_data(self):
        """Update parent/shared_of cached data, for example share count"""
        if self.share_of:
            self.share_of.cache_data(commit=True)
        if self.parent:
            self.parent.cache_data(commit=True)
            if self.parent.share_of:
                self.parent.share_of.cache_data(commit=True)
        if self.root_parent:
            self.root_parent.cache_data(commit=True)

    def create_activity(self, activity_type: ActivityType) -> Activity:
        """
        Create and link a matching activity.
        """
        from django.contrib.contenttypes.models import ContentType as DjangoContentType
        return Activity.objects.create(
            content_type=DjangoContentType.objects.get_for_model(Content),
            fid=f"{self.author.fid}#activities/{uuid4()}",
            object_id=self.id,
            profile=self.author,
            type=activity_type,
        )

    def extract_mentions(self):
        # TODO locally created mentions should not have to be ripped out of text
        # For now we just rip out diaspora style mentions until we have UI layer
        from socialhome.users.models import Profile
        mentions = re.findall(r'@{[^;]+; [\w.-]+@[^}]+}', self.text)
        if not mentions:
            self.mentions.clear()
        handles = {s.split(';')[1].strip(' }') for s in mentions}

        existing_handles = set(self.mentions.values_list('handle', flat=True))
        to_remove = existing_handles.difference(handles)
        to_add = handles.difference(existing_handles)
        for handle in to_remove:
            try:
                self.mentions.remove(Profile.objects.get(handle=handle))
            except Profile.DoesNotExist:
                pass
        for handle in to_add:
            try:
                self.mentions.add(Profile.objects.get(handle=handle))
            except Profile.DoesNotExist:
                pass

    def get_absolute_url(self):
        if self.slug:
            return reverse("content:view-by-slug",
                           kwargs={
                               "pk": self.id,
                               "slug": self.slug
                           })
        return reverse("content:view", kwargs={"pk": self.id})

    @property
    def has_twitter_oembed(self):
        return self.rendered.find('class="twitter-tweet"') > -1

    @property
    def humanized_timestamp(self):
        """Human readable timestamp ie '2 hours ago'."""
        return arrow.get(self.modified).humanize()

    @cached_property
    def root(self):
        """Get root content if a reply or share."""
        if self.content_type == ContentType.CONTENT:
            return self
        elif self.content_type == ContentType.REPLY:
            return self.parent.root
        elif self.content_type == ContentType.SHARE:
            return self.share_of.root

    @property
    def timestamp(self):
        return arrow.get(self.modified).format()

    @property
    def url(self):
        return "%s%s" % (settings.SOCIALHOME_URL, self.get_absolute_url())

    @property
    def url_uuid(self):
        return "%s%s" % (settings.SOCIALHOME_URL,
                         reverse("content:view-by-uuid",
                                 kwargs={"uuid": self.uuid}))

    @staticmethod
    @memoize(timeout=604800)  # a week
    def has_shared(content_id, profile_id):
        return Content.objects.filter(id=content_id,
                                      shares__author_id=profile_id).exists()

    def save(self, *args, **kwargs):
        if self.parent and self.share_of:
            raise ValueError("Can't be both a reply and a share!")
        self.cache_data()

        if self.parent:
            self.content_type = ContentType.REPLY
            # Ensure replies have sane
            self.visibility = self.root.visibility
            self.pinned = False
            self.root_parent = self.root
        elif self.share_of:
            self.content_type = ContentType.SHARE

        if not self.uuid:
            self.uuid = uuid4()
        if not self.pk and self.local:
            if not self.guid:
                self.guid = str(self.uuid)
            if not self.fid:
                self.fid = self.url_uuid
            if self.pinned:
                max_order = Content.objects.top_level().filter(
                    author=self.author).aggregate(Max("order"))["order__max"]
                if max_order is not None:  # If max_order is None, there is likely to be no content yet
                    self.order = max_order + 1

        if not self.fid and not self.guid:
            raise ValueError("Content must have either a fid or a guid")

        self.fix_local_uploads()
        super().save(*args, **kwargs)
        self.cache_related_object_data()

    def save_tags(self, tags):
        """Save given tag relations."""
        current = set(self.tags.values_list("name", flat=True))
        if tags == current:
            return
        to_add = tags - current
        tags_to_add = []
        for tag_name in to_add:
            tag, _created = Tag.objects.get_or_create(name=tag_name)
            tags_to_add.append(tag)
        final_tags = tags_to_add + list(
            Tag.objects.filter(name__in=tags & current))
        self.tags.set(final_tags)

    def share(self, profile):
        """Share this content as the profile given."""
        if self.content_type != ContentType.CONTENT:
            # TODO: support sharing replies too
            raise ValidationError("Can only share top level content.")
        if self.author == profile:
            raise ValidationError("Cannot share own content")
        if not self.visible_for_user(profile.user):
            raise ValidationError(
                "Content to be shared is not visible to sharer.")
        if self.shares.filter(author=profile).exists():
            raise ValidationError("Profile has already shared this content.")
        # Use get or created as a safety to stop duplicates
        share, _created = Content.objects.get_or_create(author=profile,
                                                        share_of=self,
                                                        defaults={
                                                            "visibility":
                                                            self.visibility,
                                                        })
        delete_memoized(Content.has_shared, self.id, profile.id)
        return share

    def unshare(self, profile):
        """Unshare this content as the profile given."""
        if not self.shares.filter(author=profile).exists():
            raise ValidationError("No share found.")
        try:
            share = Content.objects.get(author=profile, share_of=self)
        except Content.DoesNotExist:
            # Something got before us
            pass
        else:
            share.delete()
            delete_memoized(Content.has_shared, self.id, profile.id)

    @cached_property
    def is_nsfw(self):
        return self.text.lower().find("#nsfw") > -1

    @property
    def effective_modified(self):
        if self.remote_created:
            return self.remote_created
        return self.modified

    @property
    def edited(self):
        """Determine whether Content has been edited.

        Because we do multiple saves in some cases on creation, for example for oEmbed or OpenGraph,
        and a remote content could be delivered multiple times within a short time period, for example via
        relay and original node, we allow 15 minutes before deciding that the content has been edited.

        TODO: it would make sense to store an "edited" flag on the model itself.
        """
        return self.modified > self.created + datetime.timedelta(minutes=15)

    @cached_property
    def short_text(self):
        return truncatechars(self.text, 50) or ""

    @property
    def short_text_inline(self):
        return self.short_text.replace("\n", " ").replace("\r", "")

    @cached_property
    def slug(self):
        return slugify(self.short_text)

    @cached_property
    def channel_group_name(self):
        """Make a safe Channel group name.

        ASCII or hyphens or periods only.
        """
        # TODO use only id
        return ("%s_%s" % (self.id, self.uuid))

    def render(self):
        """Pre-render text to Content.rendered."""
        text = self.get_and_linkify_tags()
        rendered = commonmark(text).strip()
        rendered = process_text_links(rendered)
        if self.is_nsfw:
            rendered = make_nsfw_safe(rendered)
        if self.show_preview:
            if self.oembed:
                rendered = "%s<br>%s" % (rendered, self.oembed.oembed)
            if self.opengraph:
                rendered = "%s%s" % (rendered,
                                     render_to_string(
                                         "content/_og_preview.html",
                                         {"opengraph": self.opengraph}))
        self.rendered = rendered
        Content.objects.filter(id=self.id).update(rendered=rendered)

    def get_and_linkify_tags(self):
        """Find tags in text and convert them to Markdown links.

        Save found tags to the content.
        """
        found_tags = set()
        lines = self.text.splitlines(keepends=True)
        final_words = []
        code_block = False
        # Check each line separately
        for line in lines:
            if line[0:3] == "```":
                code_block = not code_block
            # noinspection PyTypeChecker
            if line.find("#") == -1 or line[0:4] == "    " or code_block:
                # Just add the whole line
                final_words.append(line)
                continue
            # Check each word separately
            # noinspection PyTypeChecker
            words = line.split(" ")
            for word in words:
                # noinspection PyTypeChecker
                candidate = word.strip().strip("([]),.!?:")
                # noinspection PyTypeChecker
                if candidate.startswith("#"):
                    # noinspection PyTypeChecker
                    candidate = candidate.strip("#")
                    if test_tag(candidate.lower()):
                        # Tag
                        found_tags.add(candidate.lower())
                        try:
                            # noinspection PyTypeChecker
                            tag_word = word.replace(
                                "#%s" % candidate, "[#%s](%s)" %
                                (candidate,
                                 reverse("streams:tag",
                                         kwargs={"name": candidate.lower()})))
                            final_words.append(tag_word)
                        except NoReverseMatch:
                            # Don't linkify, seems we can't generate an url for it
                            final_words.append(word)
                    else:
                        # Not tag
                        final_words.append(word)
                else:
                    final_words.append(word)
        text = " ".join(final_words)
        self.save_tags(found_tags)
        return text

    def fix_local_uploads(self):
        """Fix the markdown URL of local uploads.

        Basically these need to be remote compatible. So make this:

            ![](/media/markdownx/12345.jpg

        to this:

            ![](https://socialhome.domain/media/markdownx/12345.jpg
        """
        self.text = re.sub(r"!\[\]\(/media/uploads/",
                           "![](%s/media/uploads/" % settings.SOCIALHOME_URL,
                           self.text)

    def visible_for_user(self, user):
        """Check if visible to given user.

        Mirrors logic in `ContentQuerySet.visible_for_user`.
        """
        if self.visibility == Visibility.PUBLIC:
            return True
        if user.is_authenticated:
            if self.author == user.profile or self.visibility == Visibility.SITE:
                return True
            if self.limited_visibilities.filter(id=user.profile.id).exists():
                return True
        return False
예제 #16
0
class Task(models.Model):
    shop = models.ForeignKey("shuup.Shop",
                             verbose_name=_("shop"),
                             related_name="tasks")
    name = models.CharField(verbose_name=_("name"), max_length=60)
    type = models.ForeignKey(TaskType,
                             verbose_name=_("task type"),
                             related_name="tasks")
    status = EnumIntegerField(TaskStatus,
                              default=TaskStatus.NEW,
                              verbose_name=_("status"))
    priority = models.PositiveIntegerField(default=0,
                                           verbose_name=_("priority"),
                                           db_index=True)
    creator = models.ForeignKey("shuup.Contact",
                                blank=True,
                                null=True,
                                related_name="creted_tasks",
                                verbose_name=_("creator"))
    assigned_to = models.ForeignKey("shuup.Contact",
                                    blank=True,
                                    null=True,
                                    related_name="assigned_tasks",
                                    verbose_name=_("assigned to"))
    completed_by = models.ForeignKey("shuup.Contact",
                                     blank=True,
                                     null=True,
                                     related_name="completed_tasks",
                                     verbose_name=_("completed by"))
    completed_on = models.DateTimeField(verbose_name=_("completed on"),
                                        null=True,
                                        blank=True)
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      db_index=True,
                                      verbose_name=_("created on"))
    modified_on = models.DateTimeField(auto_now=True,
                                       editable=False,
                                       db_index=True,
                                       verbose_name=_("modified on"))

    objects = TaskQuerySet.as_manager()

    def __str__(self):
        return self.name

    def assign(self, user):
        self.assigned_to = user
        self.status = TaskStatus.IN_PROGRESS
        self.save()

    def delete(self):
        self.status = TaskStatus.DELETED
        self.save(update_fields=["status"])
        self.add_log_entry("Success! Deleted (soft).",
                           kind=LogEntryKind.DELETION)

    def comment(self,
                contact,
                comment,
                visibility=TaskCommentVisibility.PUBLIC):
        comment = TaskComment(task=self,
                              author=contact,
                              body=comment,
                              visibility=visibility)
        comment.full_clean()
        comment.save()
        return comment

    def set_in_progress(self):
        self.status = TaskStatus.IN_PROGRESS
        self.add_log_entry("Info! In progress.", kind=LogEntryKind.EDIT)
        self.save()

    def set_completed(self, contact):
        self.completed_by = contact
        self.completed_on = now()
        self.status = TaskStatus.COMPLETED
        self.add_log_entry("Success! Completed.", kind=LogEntryKind.EDIT)
        self.save()

    def get_completion_time(self):
        if self.completed_on:
            return (self.completed_on - self.created_on)
예제 #17
0
class Product(TaxableItem, AttributableMixin, TranslatableModel):
    COMMON_SELECT_RELATED = ("type", "primary_image", "tax_class")

    # Metadata
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      db_index=True,
                                      verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now=True,
                                       editable=False,
                                       verbose_name=_('modified on'))
    deleted = models.BooleanField(default=False,
                                  editable=False,
                                  db_index=True,
                                  verbose_name=_('deleted'))

    # Behavior
    mode = EnumIntegerField(ProductMode,
                            default=ProductMode.NORMAL,
                            verbose_name=_('mode'))
    variation_parent = models.ForeignKey("self",
                                         null=True,
                                         blank=True,
                                         related_name='variation_children',
                                         on_delete=models.PROTECT,
                                         verbose_name=_('variation parent'))
    stock_behavior = EnumIntegerField(
        StockBehavior,
        default=StockBehavior.UNSTOCKED,
        verbose_name=_('stock'),
        help_text=_(
            "Set to stocked if inventory should be managed within Shuup."))
    shipping_mode = EnumIntegerField(
        ShippingMode,
        default=ShippingMode.SHIPPED,
        verbose_name=_('shipping mode'),
        help_text=_("Set to shipped if the product requires shipment."))
    sales_unit = models.ForeignKey(
        "SalesUnit",
        verbose_name=_('sales unit'),
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        help_text=
        _("Select a sales unit for your product. "
          "This is shown in your store front and is used to determine whether the product can be purchased using "
          "fractional amounts. Sales units are defined in Products - Sales Units."
          ))
    tax_class = models.ForeignKey(
        "TaxClass",
        verbose_name=_('tax class'),
        on_delete=models.PROTECT,
        help_text=
        _("Select a tax class for your product. "
          "The tax class is used to determine which taxes to apply to your product. "
          "Tax classes are defined in Settings - Tax Classes. "
          "The rules by which taxes are applied are defined in Settings - Tax Rules."
          ))

    # Identification
    type = models.ForeignKey(
        "ProductType",
        related_name='products',
        on_delete=models.PROTECT,
        db_index=True,
        verbose_name=_('product type'),
        help_text=_(
            "Select a product type for your product. "
            "These allow you to configure custom attributes to help with classification and analysis."
        ))
    sku = models.CharField(
        db_index=True,
        max_length=128,
        verbose_name=_('SKU'),
        unique=True,
        help_text=
        _("Enter a SKU (Stock Keeping Unit) number for your product. "
          "This is a product identification code that helps you track it through your inventory. "
          "People often use the number by the barcode on the product, "
          "but you can set up any numerical system you want to keep track of products."
          ))
    gtin = models.CharField(
        blank=True,
        max_length=40,
        verbose_name=_('GTIN'),
        help_text=_(
            "You can enter a Global Trade Item Number. "
            "This is typically a 14 digit identification number for all of your trade items. "
            "It can often be found by the barcode."))
    barcode = models.CharField(
        blank=True,
        max_length=40,
        verbose_name=_('barcode'),
        help_text=
        _("You can enter the barcode number for your product. This is useful for inventory/stock tracking and analysis."
          ))
    accounting_identifier = models.CharField(
        max_length=32, blank=True, verbose_name=_('bookkeeping account'))
    profit_center = models.CharField(max_length=32,
                                     verbose_name=_('profit center'),
                                     blank=True)
    cost_center = models.CharField(max_length=32,
                                   verbose_name=_('cost center'),
                                   blank=True)

    # Physical dimensions
    width = MeasurementField(
        unit="mm",
        verbose_name=_('width (mm)'),
        help_text=_(
            "Set the measured width of your product or product packaging. "
            "This will provide customers with your product size and help with calculating shipping costs."
        ))
    height = MeasurementField(
        unit="mm",
        verbose_name=_('height (mm)'),
        help_text=_(
            "Set the measured height of your product or product packaging. "
            "This will provide customers with your product size and help with calculating shipping costs."
        ))
    depth = MeasurementField(
        unit="mm",
        verbose_name=_('depth (mm)'),
        help_text=
        _("Set the measured depth or length of your product or product packaging. "
          "This will provide customers with your product size and help with calculating shipping costs."
          ))
    net_weight = MeasurementField(
        unit="g",
        verbose_name=_('net weight (g)'),
        help_text=_(
            "Set the measured weight of your product WITHOUT its packaging. "
            "This will provide customers with your product weight."))
    gross_weight = MeasurementField(
        unit="g",
        verbose_name=_('gross weight (g)'),
        help_text=_(
            "Set the measured gross Weight of your product WITH its packaging. "
            "This will help with calculating shipping costs."))

    # Misc.
    manufacturer = models.ForeignKey(
        "Manufacturer",
        blank=True,
        null=True,
        verbose_name=_('manufacturer'),
        on_delete=models.PROTECT,
        help_text=
        _("Select a manufacturer for your product. These are defined in Products Settings - Manufacturers"
          ))
    primary_image = models.ForeignKey(
        "ProductMedia",
        null=True,
        blank=True,
        related_name="primary_image_for_products",
        on_delete=models.SET_NULL,
        verbose_name=_("primary image"))

    translations = TranslatedFields(
        name=models.CharField(
            max_length=256,
            verbose_name=_('name'),
            help_text=
            _("Enter a descriptive name for your product. This will be its title in your store."
              )),
        description=models.TextField(
            blank=True,
            verbose_name=_('description'),
            help_text=
            _("To make your product stand out, give it an awesome description. "
              "This is what will help your shoppers learn about your products. "
              "It will also help shoppers find them in the store and on the web."
              )),
        short_description=models.CharField(
            max_length=150,
            blank=True,
            verbose_name=_('short description'),
            help_text=
            _("Enter a short description for your product. "
              "The short description will be used to get the attention of your "
              "customer with a small but precise description of your product."
              )),
        slug=models.SlugField(
            verbose_name=_('slug'),
            max_length=255,
            blank=True,
            null=True,
            help_text=_(
                "Enter a URL Slug for your product. This is what your product page URL will be. "
                "A default will be created using the product name.")),
        keywords=models.TextField(
            blank=True,
            verbose_name=_('keywords'),
            help_text=_(
                "You can enter keywords that describe your product. "
                "This will help your shoppers learn about your products. "
                "It will also help shoppers find them in the store and on the web."
            )),
        status_text=models.CharField(
            max_length=128,
            blank=True,
            verbose_name=_('status text'),
            help_text=_(
                'This text will be shown alongside the product in the shop. '
                'It is useful for informing customers of special stock numbers or preorders. '
                '(Ex.: "Available in a month")')),
        variation_name=models.CharField(
            max_length=128,
            blank=True,
            verbose_name=_('variation name'),
            help_text=_(
                "You can enter a name for the variation of your product. "
                "This could be for example different colors or versions.")))

    objects = ProductQuerySet.as_manager()

    class Meta:
        ordering = ('-id', )
        verbose_name = _('product')
        verbose_name_plural = _('products')

    def __str__(self):
        try:
            return u"%s" % self.name
        except ObjectDoesNotExist:
            return self.sku

    def get_shop_instance(self, shop, allow_cache=False):
        """
        :type shop: shuup.core.models.Shop
        :rtype: shuup.core.models.ShopProduct
        """

        # FIXME: Temporary removed the cache to prevent parler issues
        # Uncomment this as soon as https://github.com/shuup/shuup/issues/1323 is fixed
        # and Django Parler version is bumped with the fix

        # from shuup.core.utils import context_cache
        # key, val = context_cache.get_cached_value(
        #     identifier="shop_product", item=self, context={"shop": shop}, allow_cache=allow_cache)
        # if val is not None:
        #     return val
        shop_inst = self.shop_products.get(shop_id=shop.id)
        # context_cache.set_cached_value(key, shop_inst)
        return shop_inst

    def get_priced_children(self, context, quantity=1):
        """
        Get child products with price infos sorted by price.

        :rtype: list[(Product,PriceInfo)]
        :return:
          List of products and their price infos sorted from cheapest to
          most expensive.
        """
        from shuup.core.models import ShopProduct
        priced_children = []
        for child in self.variation_children.all():
            try:
                shop_product = child.get_shop_instance(context.shop)
            except ShopProduct.DoesNotExist:
                continue

            if shop_product.is_orderable(supplier=None,
                                         customer=context.customer,
                                         quantity=1):
                priced_children.append(
                    (child, child.get_price_info(context, quantity=quantity)))

        return sorted(priced_children, key=(lambda x: x[1].price))

    def get_cheapest_child_price(self, context, quantity=1):
        price_info = self.get_cheapest_child_price_info(context, quantity)
        if price_info:
            return price_info.price

    def get_child_price_range(self, context, quantity=1):
        """
        Get the prices for cheapest and the most expensive child

        The attribute used for sorting is `PriceInfo.price`.

        Return (`None`, `None`) if `self.variation_children` do not exist.
        This is because we cannot return anything sensible.

        :type context: shuup.core.pricing.PricingContextable
        :type quantity: int
        :return: a tuple of prices
        :rtype: (shuup.core.pricing.Price, shuup.core.pricing.Price)
        """
        items = [
            c.get_price_info(context, quantity=quantity)
            for c in self.variation_children.all()
        ]
        if not items:
            return (None, None)

        infos = sorted(items, key=lambda x: x.price)
        return (infos[0].price, infos[-1].price)

    def get_cheapest_child_price_info(self, context, quantity=1):
        """
        Get the `PriceInfo` of the cheapest variation child

        The attribute used for sorting is `PriceInfo.price`.

        Return `None` if `self.variation_children` do not exist.
        This is because we cannot return anything sensible.

        :type context: shuup.core.pricing.PricingContextable
        :rtype: shuup.core.pricing.PriceInfo
        """
        items = [
            c.get_price_info(context, quantity=quantity)
            for c in self.variation_children.all()
        ]
        if not items:
            return None

        return sorted(items, key=lambda x: x.price)[0]

    def get_price_info(self, context, quantity=1):
        """
        Get `PriceInfo` object for the product in given context.

        Returned `PriceInfo` object contains calculated `price` and
        `base_price`.  The calculation of prices is handled in the
        current pricing module.

        :type context: shuup.core.pricing.PricingContextable
        :rtype: shuup.core.pricing.PriceInfo
        """
        from shuup.core.pricing import get_price_info
        return get_price_info(product=self, context=context, quantity=quantity)

    def get_price(self, context, quantity=1):
        """
        Get price of the product within given context.

        .. note::

           When the current pricing module implements pricing steps, it
           is possible that ``p.get_price(ctx) * 123`` is not equal to
           ``p.get_price(ctx, quantity=123)``, since there could be
           quantity discounts in effect, but usually they are equal.

        :type context: shuup.core.pricing.PricingContextable
        :rtype: shuup.core.pricing.Price
        """
        return self.get_price_info(context, quantity).price

    def get_base_price(self, context, quantity=1):
        """
        Get base price of the product within given context.

        Base price differs from the (effective) price when there are
        discounts in effect.

        :type context: shuup.core.pricing.PricingContextable
        :rtype: shuup.core.pricing.Price
        """
        return self.get_price_info(context, quantity=quantity).base_price

    def get_available_attribute_queryset(self):
        if self.type_id:
            return self.type.attributes.visible()
        else:
            return Attribute.objects.none()

    def get_available_variation_results(self):
        """
        Get a dict of `combination_hash` to product ID of variable variation results.

        :return: Mapping of combination hashes to product IDs
        :rtype: dict[str, int]
        """
        return dict(
            ProductVariationResult.objects.filter(product=self).filter(
                status=1).values_list("combination_hash", "result_id"))

    def get_all_available_combinations(self):
        """
        Generate all available combinations of variation variables.

        If the product is not a variable variation parent, the iterator is empty.

        Because of possible combinatorial explosion this is a generator function.
        (For example 6 variables with 5 options each explodes to 15,625 combinations.)

        :return: Iterable of combination information dicts.
        :rtype: Iterable[dict]
        """
        return get_all_available_combinations(self)

    def clear_variation(self):
        """
        Fully remove variation information.

        Make this product a non-variation parent.
        """
        self.simplify_variation()
        for child in self.variation_children.all():
            if child.variation_parent_id == self.pk:
                child.unlink_from_parent()
        self.verify_mode()
        self.save()

    def simplify_variation(self):
        """
        Remove variation variables from the given variation parent, turning it
        into a simple variation (or a normal product, if it has no children).

        :param product: Variation parent to not be variable any longer.
        :type product: shuup.core.models.Product
        """
        ProductVariationVariable.objects.filter(product=self).delete()
        ProductVariationResult.objects.filter(product=self).delete()
        self.verify_mode()
        self.save()

    @staticmethod
    def _get_slug_name(self, translation=None):
        if self.deleted:
            return None
        return getattr(translation, "name", self.sku)

    def save(self, *args, **kwargs):
        self.clean()
        if self.net_weight and self.net_weight > 0:
            self.gross_weight = max(self.net_weight, self.gross_weight)
        rv = super(Product, self).save(*args, **kwargs)
        generate_multilanguage_slugs(self, self._get_slug_name)
        return rv

    def clean(self):
        pre_clean.send(type(self), instance=self)
        super(Product, self).clean()
        post_clean.send(type(self), instance=self)

    def delete(self, using=None):
        raise NotImplementedError(
            "Not implemented: Use `soft_delete()` for products.")

    def soft_delete(self, user=None):
        if not self.deleted:
            self.deleted = True
            self.add_log_entry("Deleted.",
                               kind=LogEntryKind.DELETION,
                               user=user)
            # Bypassing local `save()` on purpose.
            super(Product, self).save(update_fields=("deleted", ))

    def verify_mode(self):
        if ProductPackageLink.objects.filter(parent=self).exists():
            self.mode = ProductMode.PACKAGE_PARENT
            self.external_url = None
            self.variation_children.clear()
        elif ProductVariationVariable.objects.filter(product=self).exists():
            self.mode = ProductMode.VARIABLE_VARIATION_PARENT
        elif self.variation_children.exists():
            if ProductVariationResult.objects.filter(product=self).exists():
                self.mode = ProductMode.VARIABLE_VARIATION_PARENT
            else:
                self.mode = ProductMode.SIMPLE_VARIATION_PARENT
            self.external_url = None
            ProductPackageLink.objects.filter(parent=self).delete()
        elif self.variation_parent:
            self.mode = ProductMode.VARIATION_CHILD
            ProductPackageLink.objects.filter(parent=self).delete()
            self.variation_children.clear()
            self.external_url = None
        else:
            self.mode = ProductMode.NORMAL

    def unlink_from_parent(self):
        if self.variation_parent:
            parent = self.variation_parent
            self.variation_parent = None
            self.save()
            parent.verify_mode()
            self.verify_mode()
            self.save()
            ProductVariationResult.objects.filter(result=self).delete()
            return True

    def link_to_parent(self, parent, variables=None, combination_hash=None):
        """
        :param parent: The parent to link to.
        :type parent: Product
        :param variables: Optional dict of {variable identifier: value identifier} for complex variable linkage
        :type variables: dict|None
        :param combination_hash: Optional combination hash (for variable variations), if precomputed. Mutually
                                 exclusive with `variables`
        :type combination_hash: str|None

        """
        if combination_hash:
            if variables:
                raise ValueError(
                    "`combination_hash` and `variables` are mutually exclusive"
                )
            variables = True  # Simplifies the below invariant checks

        self._raise_if_cant_link_to_parent(parent, variables)

        self.unlink_from_parent()
        self.variation_parent = parent
        self.verify_mode()
        self.save()
        if not parent.is_variation_parent():
            parent.verify_mode()
            parent.save()

        if variables:
            if not combination_hash:  # No precalculated hash, need to figure that out
                combination_hash = get_combination_hash_from_variable_mapping(
                    parent, variables=variables)

            pvr = ProductVariationResult.objects.create(
                product=parent, combination_hash=combination_hash, result=self)
            if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT:
                parent.verify_mode()
                parent.save()
            return pvr
        else:
            return True

    def _raise_if_cant_link_to_parent(self, parent, variables):
        """
        Validates relation possibility for `self.link_to_parent()`

        :param parent: parent product of self
        :type parent: Product
        :param variables:
        :type variables: dict|None
        """
        if parent.is_variation_child():
            raise ImpossibleProductModeException(_(
                "Multilevel parentage hierarchies aren't supported (parent is a child already)"
            ),
                                                 code="multilevel")
        if parent.mode == ProductMode.VARIABLE_VARIATION_PARENT and not variables:
            raise ImpossibleProductModeException(_(
                "Parent is a variable variation parent, yet variables were not passed"
            ),
                                                 code="no_variables")
        if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT and variables:
            raise ImpossibleProductModeException(
                "Parent is a simple variation parent, yet variables were passed",
                code="extra_variables")
        if self.mode == ProductMode.SIMPLE_VARIATION_PARENT:
            raise ImpossibleProductModeException(_(
                "Multilevel parentage hierarchies aren't supported (this product is a simple variation parent)"
            ),
                                                 code="multilevel")
        if self.mode == ProductMode.VARIABLE_VARIATION_PARENT:
            raise ImpossibleProductModeException(_(
                "Multilevel parentage hierarchies aren't supported (this product is a variable variation parent)"
            ),
                                                 code="multilevel")

    def make_package(self, package_def):
        if self.mode != ProductMode.NORMAL:
            raise ImpossibleProductModeException(_(
                "Product is currently not a normal product, can't turn into package"
            ),
                                                 code="abnormal")

        for child_product, quantity in six.iteritems(package_def):
            if child_product.pk == self.pk:
                raise ImpossibleProductModeException(
                    _("Package can't contain itself"), code="content")
            # :type child_product: Product
            if child_product.is_variation_parent():
                raise ImpossibleProductModeException(
                    _("Variation parents can not belong into a package"),
                    code="abnormal")
            if child_product.is_container():
                raise ImpossibleProductModeException(
                    _("Packages can't be nested"), code="multilevel")
            if quantity <= 0:
                raise ImpossibleProductModeException(
                    _("Quantity %s is invalid") % quantity, code="quantity")
            ProductPackageLink.objects.create(parent=self,
                                              child=child_product,
                                              quantity=quantity)
        self.verify_mode()

    def get_package_child_to_quantity_map(self):
        if self.is_container():
            product_id_to_quantity = dict(
                ProductPackageLink.objects.filter(parent=self).values_list(
                    "child_id", "quantity"))
            products = dict((p.pk, p) for p in Product.objects.filter(
                pk__in=product_id_to_quantity.keys()))
            return {
                products[product_id]: quantity
                for (product_id,
                     quantity) in six.iteritems(product_id_to_quantity)
            }
        return {}

    def is_variation_parent(self):
        return self.mode in (ProductMode.SIMPLE_VARIATION_PARENT,
                             ProductMode.VARIABLE_VARIATION_PARENT)

    def is_variation_child(self):
        return (self.mode == ProductMode.VARIATION_CHILD)

    def get_variation_siblings(self):
        return Product.objects.filter(
            variation_parent=self.variation_parent).exclude(pk=self.pk)

    def is_package_parent(self):
        return (self.mode == ProductMode.PACKAGE_PARENT)

    def is_subscription_parent(self):
        return (self.mode == ProductMode.SUBSCRIPTION)

    def is_package_child(self):
        return ProductPackageLink.objects.filter(child=self).exists()

    def get_all_package_parents(self):
        return Product.objects.filter(
            pk__in=(ProductPackageLink.objects.filter(
                child=self).values_list("parent", flat=True)))

    def get_all_package_children(self):
        return Product.objects.filter(
            pk__in=(ProductPackageLink.objects.filter(
                parent=self).values_list("child", flat=True)))

    def get_public_media(self):
        return self.media.filter(
            enabled=True, public=True).exclude(kind=ProductMediaKind.IMAGE)

    def is_stocked(self):
        return (self.stock_behavior == StockBehavior.STOCKED)

    def is_container(self):
        return (self.is_package_parent() or self.is_subscription_parent())
예제 #18
0
class Shop(ChangeProtected, TranslatableShuupModel):
    protected_fields = ["currency", "prices_include_tax"]
    change_protect_message = _("The following fields cannot be changed since there are existing orders for this shop")

    created_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now=True, editable=False, db_index=True, verbose_name=_('modified on'))
    identifier = InternalIdentifierField(unique=True, max_length=128)
    domain = models.CharField(max_length=128, blank=True, null=True, unique=True, verbose_name=_("domain"), help_text=_(
        "Your shop domain name. Use this field to configure the URL that is used to visit your site. "
        "Note: this requires additional configuration through your internet domain registrar."
    ))
    status = EnumIntegerField(ShopStatus, default=ShopStatus.DISABLED, verbose_name=_("status"), help_text=_(
        "Your shop status. Disable your shop if it is no longer in use."
    ))
    owner = models.ForeignKey("Contact", blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_("contact"))
    options = JSONField(blank=True, null=True, verbose_name=_("options"))
    currency = CurrencyField(default=_get_default_currency, verbose_name=_("currency"), help_text=_(
        "The primary shop currency. This is the currency used when selling your products."
    ))
    prices_include_tax = models.BooleanField(default=True, verbose_name=_("prices include tax"), help_text=_(
        "This option defines whether product prices entered in admin include taxes. "
        "Note this behavior can be overridden with contact group pricing."
    ))
    logo = FilerImageField(
        verbose_name=_("logo"), blank=True, null=True, on_delete=models.SET_NULL,
        help_text=_("Shop logo. Will be shown at theme."), related_name="shop_logos")

    favicon = FilerImageField(
        verbose_name=_("favicon"), blank=True, null=True, on_delete=models.SET_NULL,
        help_text=_("Shop favicon. Will be shown next to the address on browser."), related_name="shop_favicons")

    maintenance_mode = models.BooleanField(verbose_name=_("maintenance mode"), default=False, help_text=_(
        "Check this if you would like to make your shop temporarily unavailable while you do some shop maintenance."
    ))
    contact_address = models.ForeignKey(
        "MutableAddress", verbose_name=_("contact address"), blank=True, null=True, on_delete=models.SET_NULL)
    staff_members = models.ManyToManyField(
        settings.AUTH_USER_MODEL, blank=True, related_name="+", verbose_name=_('staff members'))

    translations = TranslatedFields(
        name=models.CharField(max_length=64, verbose_name=_("name"), help_text=_(
            "The shop name. This name is displayed throughout admin."
        )),
        public_name=models.CharField(max_length=64, verbose_name=_("public name"), help_text=_(
            "The public shop name. This name is displayed in the store front and in any customer email correspondence."
        )),
        maintenance_message=models.CharField(
            max_length=300, blank=True, verbose_name=_("maintenance message"), help_text=_(
                "The message to display to customers while your shop is in maintenance mode."
            )
        )
    )

    objects = ShopManager()

    class Meta:
        verbose_name = _('shop')
        verbose_name_plural = _('shops')

    def __str__(self):
        return self.safe_translation_getter("name", default="Shop %d" % self.pk)

    def create_price(self, value):
        """
        Create a price with given value and settings of this shop.

        Takes the ``prices_include_tax`` and ``currency`` settings of
        this Shop into account.

        :type value: decimal.Decimal|int|str
        :rtype: shuup.core.pricing.Price
        """
        if self.prices_include_tax:
            return TaxfulPrice(value, self.currency)
        else:
            return TaxlessPrice(value, self.currency)

    def _are_changes_protected(self):
        return Order.objects.filter(shop=self).exists()
예제 #19
0
class ProductMedia(TranslatableModel):
    identifier = InternalIdentifierField(unique=True)
    product = models.ForeignKey("Product", related_name="media", on_delete=models.CASCADE, verbose_name=_('product'))
    shops = models.ManyToManyField("Shop", related_name="product_media", verbose_name=_('shops'))
    kind = EnumIntegerField(
        ProductMediaKind, db_index=True, default=ProductMediaKind.GENERIC_FILE, verbose_name=_('kind')
    )
    file = FilerFileField(blank=True, null=True, verbose_name=_('file'), on_delete=models.CASCADE)
    external_url = models.URLField(
        blank=True, null=True, verbose_name=_('URL'),
        help_text=_("Enter URL to external file. If this field is filled, the selected media doesn't apply.")
    )
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'))

    # Status
    enabled = models.BooleanField(db_index=True, default=True, verbose_name=_("enabled"))
    public = models.BooleanField(default=True, blank=True, verbose_name=_('public (shown on product page)'))
    purchased = models.BooleanField(
        default=False, blank=True, verbose_name=_('purchased (shown for finished purchases)')
    )

    translations = TranslatedFields(
        title=models.CharField(blank=True, max_length=128, verbose_name=_('title')),
        description=models.TextField(blank=True, verbose_name=_('description')),
    )

    class Meta:
        verbose_name = _('product attachment')
        verbose_name_plural = _('product attachments')
        ordering = ["ordering", ]

    def __str__(self):  # pragma: no cover
        return self.effective_title

    @property
    def effective_title(self):
        title = self.safe_translation_getter("title")
        if title:
            return title

        if self.file_id:
            return self.file.label

        if self.external_url:
            return self.external_url

        return _('attachment')

    @property
    def url(self):
        if self.external_url:
            return self.external_url
        if self.file:
            return self.file.url
        return ""

    @property
    def easy_thumbnails_thumbnailer(self):
        """
        Get `Thumbnailer` instance.

        Will return `None` if file cannot be thumbnailed.

        :rtype:easy_thumbnails.files.Thumbnailer|None
        """
        if not self.file_id:
            return None

        if self.kind != ProductMediaKind.IMAGE:
            return None

        return get_thumbnailer(self.file)

    def get_thumbnail(self, **kwargs):
        """
        Get thumbnail for image

        This will return `None` if there is no file or kind is not `ProductMediaKind.IMAGE`

        :rtype: easy_thumbnails.files.ThumbnailFile|None
        """
        kwargs.setdefault("size", (64, 64))
        kwargs.setdefault("crop", True)  # sane defaults
        kwargs.setdefault("upscale", True)  # sane defaults

        if kwargs["size"] is (0, 0):
            return None

        thumbnailer = self.easy_thumbnails_thumbnailer

        if not thumbnailer:
            return None

        return thumbnailer.get_thumbnail(thumbnail_options=kwargs)
예제 #20
0
class ShopProduct(MoneyPropped, TranslatableModel):
    shop = models.ForeignKey("Shop", related_name="shop_products", on_delete=models.CASCADE, verbose_name=_("shop"))
    product = UnsavedForeignKey(
        "Product", related_name="shop_products", on_delete=models.CASCADE, verbose_name=_("product"))
    suppliers = models.ManyToManyField(
        "Supplier", related_name="shop_products", blank=True, verbose_name=_("suppliers"), help_text=_(
            "List your suppliers here. Suppliers can be found by searching for `Suppliers`."
        )
    )

    visibility = EnumIntegerField(
        ShopProductVisibility,
        default=ShopProductVisibility.ALWAYS_VISIBLE,
        db_index=True,
        verbose_name=_("visibility"),
        help_text=mark_safe_lazy(_(
            "Choose how you want your product to be seen and found by the customers. "
            "<p>Not visible: Product will not be shown in your store front nor found in search.</p>"
            "<p>Searchable: Product will be shown in search, but not listed on any category page.</p>"
            "<p>Listed: Product will be shown on category pages, but not shown in search results.</p>"
            "<p>Always Visible: Product will be shown in your store front and found in search.</p>"
        ))
    )
    purchasable = models.BooleanField(default=True, db_index=True, verbose_name=_("purchasable"))
    visibility_limit = EnumIntegerField(
        ProductVisibility, db_index=True, default=ProductVisibility.VISIBLE_TO_ALL,
        verbose_name=_('visibility limitations'), help_text=_(
            "Select whether you want your product to have special limitations on its visibility in your store. "
            "You can make products visible to all, visible to only logged-in users, or visible only to certain "
            "customer groups."
        )
    )
    visibility_groups = models.ManyToManyField(
        "ContactGroup", related_name='visible_products', verbose_name=_('visible for groups'), blank=True, help_text=_(
            u"Select the groups you want to make your product visible for. "
            u"These groups are defined in Contacts Settings - Contact Groups."
        )
    )
    backorder_maximum = QuantityField(
        default=0, blank=True, null=True, verbose_name=_('backorder maximum'), help_text=_(
            "The number of units that can be purchased after the product is already sold out (out of stock). "
            "Set to blank for product to be purchasable without limits."
        ))
    purchase_multiple = QuantityField(default=0, verbose_name=_('purchase multiple'), help_text=_(
            "Set this to other than 0 if the product needs to be purchased in multiples. "
            "For example, if the purchase multiple is set to 2, then customers are required to order the product "
            "in multiples of 2. Not to be confused with the Minimum Purchase Quantity."
        )
    )
    minimum_purchase_quantity = QuantityField(default=1, verbose_name=_('minimum purchase quantity'), help_text=_(
            "Set a minimum number of products needed to be ordered for the purchase. "
            "This is useful for setting bulk orders and B2B purchases."
        )
    )
    limit_shipping_methods = models.BooleanField(
        default=False, verbose_name=_("limit the shipping methods"), help_text=_(
            "Enable this if you want to limit your product to use only the select shipping methods. "
            "You can select the allowed shipping method(s) in the field below - all the rest "
            "are disallowed."
        )
    )
    limit_payment_methods = models.BooleanField(
        default=False, verbose_name=_("limit the payment methods"), help_text=_(
            "Enable this if you want to limit your product to use only the select payment methods. "
            "You can select the allowed payment method(s) in the field below - all the rest "
            "are disallowed."
        )
    )
    shipping_methods = models.ManyToManyField(
        "ShippingMethod", related_name='shipping_products', verbose_name=_('shipping methods'), blank=True, help_text=_(
            "If you enabled the `Limit the payment methods` choice above, then here you can select the "
            "individual shipping methods you want to ALLOW for this product. The ones not mentioned are "
            "disabled. To change this, search for `Shipping Methods`."
        )
    )
    payment_methods = models.ManyToManyField(
        "PaymentMethod", related_name='payment_products', verbose_name=_('payment methods'), blank=True, help_text=_(
            "If you enabled the `Limit the payment methods` choice above, then here you can select the "
            "individuals payment methods you want to ALLOW for this product. The ones not mentioned are "
            "disabled. To change this, search for `Payment Methods`."
        )
    )
    primary_category = models.ForeignKey(
        "Category", related_name='primary_shop_products', verbose_name=_('primary category'), blank=True, null=True,
        on_delete=models.PROTECT, help_text=_(
            "Choose the primary category for the product. "
            "This will be the main category for classification in the system. "
            "The product will be found under this category in your store. "
            "To change this, search for `Categories`."
        )
    )
    categories = models.ManyToManyField(
        "Category", related_name='shop_products', verbose_name=_('categories'), blank=True, help_text=_(
            "Add secondary categories for your product. "
            "These are other categories that your product fits under and that it can be found by in your store."
        )
    )
    shop_primary_image = models.ForeignKey(
        "ProductMedia", null=True, blank=True,
        related_name="primary_image_for_shop_products", on_delete=models.SET_NULL,
        verbose_name=_("primary image"), help_text=_(
            "Click this to set this image as the primary display image for the product."
        )
    )

    # the default price of this product in the shop
    default_price = PriceProperty('default_price_value', 'shop.currency', 'shop.prices_include_tax')
    default_price_value = MoneyValueField(verbose_name=_("default price"), null=True, blank=True, help_text=_(
            "This is the default individual base unit (or multi-pack) price of the product. "
            "All discounts or coupons will be calculated based off of this price."
        )
    )

    minimum_price = PriceProperty('minimum_price_value', 'shop.currency', 'shop.prices_include_tax')
    minimum_price_value = MoneyValueField(verbose_name=_("minimum price"), null=True, blank=True, help_text=_(
            "This is the default price that the product cannot go under in your store, "
            "despite coupons or discounts being applied. "
            "This is useful to make sure your product price stays above the cost."
        )
    )
    available_until = models.DateTimeField(verbose_name=_("available until"), null=True, blank=True, help_text=_(
        "After this date this product will be invisible in store front."
    ))

    display_unit = models.ForeignKey(
        DisplayUnit,
        on_delete=models.CASCADE,
        null=True, blank=True,
        verbose_name=_("display unit"),
        help_text=_("Unit for displaying quantities of this product.")
    )

    translations = TranslatedFields(
        name=models.CharField(
            blank=True, null=True, max_length=256, verbose_name=_('name'),
            help_text=_("Enter a descriptive name for your product. This will be its title in your store front.")),
        description=models.TextField(
            blank=True, null=True, verbose_name=_('description'),
            help_text=_(
                "To make your product stands out, give it an awesome description. "
                "This is what will help your shoppers learn about your products. "
                "It will also help shoppers find them in the store and on the web."
            )
        ),
        short_description=models.CharField(
            blank=True, null=True, max_length=150, verbose_name=_('short description'),
            help_text=_(
                "Enter a short description for your product. The short description will "
                "be used to get the attention of your customer with a small, but "
                "precise description of your product. It also helps with getting more "
                "traffic via search engines."
            )
        ),
        status_text=models.CharField(
            max_length=128, blank=True,
            verbose_name=_('status text'),
            help_text=_(
                'This text will be shown alongside the product in the shop. '
                'It is useful for informing customers of special stock numbers or preorders. '
                '(Ex.: Available in a month)'
            )
        )
    )

    class Meta:
        unique_together = (("shop", "product",),)

    def save(self, *args, **kwargs):
        self.clean()
        super(ShopProduct, self).save(*args, **kwargs)
        for supplier in self.suppliers.enabled():
            supplier.module.update_stock(product_id=self.product.id)

    def clean(self):
        pre_clean.send(type(self), instance=self)
        super(ShopProduct, self).clean()
        if self.display_unit:
            if self.display_unit.internal_unit != self.product.sales_unit:
                raise ValidationError({'display_unit': _(
                    "Error! Invalid display unit: Internal unit of "
                    "the selected display unit does not match "
                    "with the sales unit of the product.")})
        post_clean.send(type(self), instance=self)

    def is_list_visible(self):
        """
        Return True if this product should be visible in listings in general,
        without taking into account any other visibility limitations.

        :rtype: bool
        """
        if self.product.deleted:
            return False
        if not self.listed:
            return False
        if self.product.is_variation_child():
            return False
        return True

    @property
    def primary_image(self):
        if self.shop_primary_image_id:
            return self.shop_primary_image
        else:
            return self.product.primary_image

    @property
    def searchable(self):
        return self.visibility in (ShopProductVisibility.SEARCHABLE, ShopProductVisibility.ALWAYS_VISIBLE)

    @property
    def listed(self):
        return self.visibility in (ShopProductVisibility.LISTED, ShopProductVisibility.ALWAYS_VISIBLE)

    @property
    def visible(self):
        return not (self.visibility == ShopProductVisibility.NOT_VISIBLE)

    @property
    def public_primary_image(self):
        primary_image = self.primary_image
        return primary_image if primary_image and primary_image.public else None

    def get_visibility_errors(self, customer):
        if self.product.deleted:
            yield ValidationError(_("This product has been deleted."), code="product_deleted")

        if customer and customer.is_all_seeing:  # None of the further conditions matter for omniscient customers.
            return

        if not self.visible:
            yield ValidationError(_("This product is not visible."), code="product_not_visible")

        if self.available_until and self.available_until <= now():
            yield ValidationError(
                _("Error! This product is not available until the current date."),
                code="product_not_available"
            )

        is_logged_in = (bool(customer) and not customer.is_anonymous)

        if not is_logged_in and self.visibility_limit != ProductVisibility.VISIBLE_TO_ALL:
            yield ValidationError(
                _("The Product is invisible to users not logged in."),
                code="product_not_visible_to_anonymous")

        if is_logged_in and self.visibility_limit == ProductVisibility.VISIBLE_TO_GROUPS:
            # TODO: Optimization
            user_groups = set(customer.groups.all().values_list("pk", flat=True))
            my_groups = set(self.visibility_groups.values_list("pk", flat=True))
            if not bool(user_groups & my_groups):
                yield ValidationError(
                    _("This product is not visible to your group."),
                    code="product_not_visible_to_group"
                )

        # TODO: Remove from Shuup 2.0
        for receiver, response in get_visibility_errors.send(ShopProduct, shop_product=self, customer=customer):
            warnings.warn("Warning! Visibility errors through signals are deprecated.", DeprecationWarning)
            for error in response:
                yield error

    def get_orderability_errors(self, supplier, quantity, customer, ignore_minimum=False):
        """
        Yield ValidationErrors that would cause this product to not be orderable.

        Shop product to be orderable it needs to be visible visible and purchasable.

        :param supplier: Supplier to order this product from. May be None.
        :type supplier: shuup.core.models.Supplier
        :param quantity: Quantity to order.
        :type quantity: int|Decimal
        :param customer: Customer contact.
        :type customer: shuup.core.models.Contact
        :param ignore_minimum: Ignore any limitations caused by quantity minimums.
        :type ignore_minimum: bool
        :return: Iterable[ValidationError]
        """
        for error in self.get_visibility_errors(customer):
            yield error

        for error in self.get_purchasability_errors(supplier, customer, quantity, ignore_minimum):
            yield error

    def get_purchasability_errors(self, supplier, customer, quantity, ignore_minimum=False):
        """
        Yield ValidationErrors that would cause this product to not be purchasable.

        Shop product to be purchasable it has to have purchasable attribute set on
        and pass all quantity and supplier checks.

        :param supplier: Supplier to order this product from. May be None.
        :type supplier: shuup.core.models.Supplier
        :param quantity: Quantity to order.
        :type quantity: int|Decimal
        :param customer: Customer contact.
        :type customer: shuup.core.models.Contact
        :param ignore_minimum: Ignore any limitations caused by quantity minimums.
        :type ignore_minimum: bool
        :return: Iterable[ValidationError]
        """
        if not self.purchasable:
            yield ValidationError(_("The product is not purchasable."), code="not_purchasable")

        for error in self.get_quantity_errors(quantity, ignore_minimum):
            yield error

        for error in self.get_supplier_errors(supplier, customer, quantity, ignore_minimum):
            yield error

        # TODO: Remove from Shuup 2.0
        for receiver, response in get_orderability_errors.send(
            ShopProduct, shop_product=self, customer=customer, supplier=supplier, quantity=quantity
        ):
            warnings.warn("Warning! Orderability errors through signals are deprecated.", DeprecationWarning)
            for error in response:
                yield error

    def get_quantity_errors(self, quantity, ignore_minimum):
        if not ignore_minimum and quantity < self.minimum_purchase_quantity:
            yield ValidationError(
                _("The purchase quantity needs to be at least %d for this product.")
                % self.minimum_purchase_quantity,
                code="purchase_quantity_not_met"
            )

        purchase_multiple = self.purchase_multiple
        if quantity > 0 and purchase_multiple > 0 and (quantity % purchase_multiple) != 0:
            p = (quantity // purchase_multiple)
            smaller_p = max(purchase_multiple, p * purchase_multiple)
            larger_p = max(purchase_multiple, (p + 1) * purchase_multiple)
            render_qty = self.unit.render_quantity
            if larger_p == smaller_p:
                message = _(
                    "The product can only be ordered in multiples of "
                    "{package_size}, for example {amount}.").format(
                        package_size=render_qty(purchase_multiple),
                        amount=render_qty(smaller_p))
            else:
                message = _(
                    "The product can only be ordered in multiples of "
                    "{package_size}, for example {smaller_amount} or "
                    "{larger_amount}.").format(
                        package_size=render_qty(purchase_multiple),
                        smaller_amount=render_qty(smaller_p),
                        larger_amount=render_qty(larger_p))
            yield ValidationError(message, code="invalid_purchase_multiple")

    def get_supplier_errors(self, supplier, customer, quantity, ignore_minimum):
        enabled_supplier = self.suppliers.enabled(shop=self.shop)
        if supplier is None and not enabled_supplier.exists():
            # `ShopProduct` must have at least one `Supplier`.
            # If supplier is not given and the `ShopProduct` itself
            # doesn't have suppliers we cannot sell this product.
            yield ValidationError(
                _("The product has no supplier."),
                code="no_supplier"
            )

        if supplier and not enabled_supplier.filter(pk=supplier.pk).exists():
            yield ValidationError(
                _("The product is not supplied by %s.") % supplier,
                code="invalid_supplier"
            )

        errors = []
        if self.product.mode == ProductMode.SIMPLE_VARIATION_PARENT:
            errors = self.get_orderability_errors_for_simple_variation_parent(supplier, customer)
        elif self.product.mode == ProductMode.VARIABLE_VARIATION_PARENT:
            errors = self.get_orderability_errors_for_variable_variation_parent(supplier, customer)
        elif self.product.is_package_parent():
            errors = self.get_orderability_errors_for_package_parent(supplier, customer, quantity, ignore_minimum)
        elif supplier:  # Test supplier orderability only for variation children and normal products
            errors = supplier.get_orderability_errors(self, quantity, customer=customer)

        for error in errors:
            yield error

    def get_orderability_errors_for_simple_variation_parent(self, supplier, customer):
        sellable = False
        for child_product in self.product.variation_children.visible(shop=self.shop, customer=customer):
            try:
                child_shop_product = child_product.get_shop_instance(self.shop)
            except ShopProduct.DoesNotExist:
                continue

            if child_shop_product.is_orderable(
                    supplier=supplier,
                    customer=customer,
                    quantity=child_shop_product.minimum_purchase_quantity,
                    allow_cache=False
            ):
                sellable = True
                break

        if not sellable:
            yield ValidationError(_("Product has no sellable children."), code="no_sellable_children")

    def get_orderability_errors_for_variable_variation_parent(self, supplier, customer):
        from shuup.core.models import ProductVariationResult
        sellable = False
        for combo in self.product.get_all_available_combinations():
            res = ProductVariationResult.resolve(self.product, combo["variable_to_value"])
            if not res:
                continue
            try:
                child_shop_product = res.get_shop_instance(self.shop)
            except ShopProduct.DoesNotExist:
                continue

            if child_shop_product.is_orderable(
                    supplier=supplier,
                    customer=customer,
                    quantity=child_shop_product.minimum_purchase_quantity,
                    allow_cache=False
            ):
                sellable = True
                break
        if not sellable:
            yield ValidationError(_("Product has no sellable children."), code="no_sellable_children")

    def get_orderability_errors_for_package_parent(self, supplier, customer, quantity, ignore_minimum):
        for child_product, child_quantity in six.iteritems(self.product.get_package_child_to_quantity_map()):
            try:
                child_shop_product = child_product.get_shop_instance(shop=self.shop, allow_cache=False)
            except ShopProduct.DoesNotExist:
                yield ValidationError(
                    "Error! %s is not available in %s." % (child_product, self.shop), code="invalid_shop")
            else:
                for error in child_shop_product.get_orderability_errors(
                        supplier=supplier,
                        quantity=(quantity * child_quantity),
                        customer=customer,
                        ignore_minimum=ignore_minimum
                ):
                    message = getattr(error, "message", "")
                    code = getattr(error, "code", None)
                    yield ValidationError("Error! %s: %s" % (child_product, message), code=code)

    def raise_if_not_orderable(self, supplier, customer, quantity, ignore_minimum=False):
        for message in self.get_orderability_errors(
            supplier=supplier, quantity=quantity, customer=customer, ignore_minimum=ignore_minimum
        ):
            raise ProductNotOrderableProblem(message.args[0])

    def raise_if_not_visible(self, customer):
        for message in self.get_visibility_errors(customer=customer):
            raise ProductNotVisibleProblem(message.args[0])

    def is_orderable(self, supplier, customer, quantity, allow_cache=True):
        """
        Product to be orderable it needs to be visible and purchasable.
        """
        key, val = context_cache.get_cached_value(
            identifier="is_orderable", item=self, context={"customer": customer},
            supplier=supplier, stock_managed=bool(supplier and supplier.stock_managed),
            quantity=quantity, allow_cache=allow_cache)
        if customer and val is not None:
            return val

        if not supplier:
            supplier = self.get_supplier(customer, quantity)

        for message in self.get_orderability_errors(supplier=supplier, quantity=quantity, customer=customer):
            if customer:
                context_cache.set_cached_value(key, False)
            return False

        if customer:
            context_cache.set_cached_value(key, True)
        return True

    def is_visible(self, customer):
        """
        Visible products are shown in store front based on customer
        or customer group limitations.
        """
        for message in self.get_visibility_errors(customer=customer):
            return False
        return True

    def is_purchasable(self, supplier, customer, quantity):
        """
        Whether product can be purchased.
        """
        for message in self.get_purchasability_errors(supplier, customer, quantity):
            return False
        return True

    @property
    def quantity_step(self):
        """
        Quantity step for purchasing this product.

        :rtype: decimal.Decimal

        Example:
            <input type="number" step="{{ shop_product.quantity_step }}">
        """
        step = self.purchase_multiple or self._sales_unit.quantity_step
        return self._sales_unit.round(step)

    @property
    def rounded_minimum_purchase_quantity(self):
        """
        The minimum purchase quantity, rounded to the sales unit's precision.

        :rtype: decimal.Decimal

        Example:
            <input type="number"
                min="{{ shop_product.rounded_minimum_purchase_quantity }}"
                value="{{ shop_product.rounded_minimum_purchase_quantity }}">

        """
        return self._sales_unit.round(self.minimum_purchase_quantity)

    @property
    def display_quantity_step(self):
        """
        Quantity step of this shop product in the display unit.

        Note: This can never be smaller than the display precision.
        """
        return max(
            self.unit.to_display(self.quantity_step),
            self.unit.display_precision)

    @property
    def display_quantity_minimum(self):
        """
        Quantity minimum of this shop product in the display unit.

        Note: This can never be smaller than the display precision.
        """
        return max(
            self.unit.to_display(self.minimum_purchase_quantity),
            self.unit.display_precision)

    @property
    def unit(self):
        """
        Unit of this product.

        :rtype: shuup.core.models.UnitInterface
        """
        return UnitInterface(self._sales_unit, self.display_unit)

    @property
    def _sales_unit(self):
        return self.product.sales_unit or PiecesSalesUnit()

    @property
    def images(self):
        return self.product.media.filter(shops=self.shop, kind=ProductMediaKind.IMAGE).order_by("ordering")

    @property
    def public_images(self):
        return self.images.filter(public=True)

    def get_supplier(self, customer=None, quantity=None, shipping_address=None):
        supplier_strategy = cached_load("SHUUP_SHOP_PRODUCT_SUPPLIERS_STRATEGY")
        kwargs = {
            "shop_product": self,
            "customer": customer,
            "quantity": quantity,
            "shipping_address": shipping_address
        }
        return supplier_strategy().get_supplier(**kwargs)

    def __str__(self):
        return self.get_name()

    def get_name(self):
        return self._safe_get_string("name")

    def get_description(self):
        return self._safe_get_string("description")

    def get_short_description(self):
        return self._safe_get_string("short_description")

    def _safe_get_string(self, key):
        return (
            self.safe_translation_getter(key, any_language=True)
            or self.product.safe_translation_getter(key, any_language=True)
        )
예제 #21
0
class Profile(TimeStampedModel):
    """Profile data for local and remote users."""
    user = models.OneToOneField(User, on_delete=models.CASCADE, null=True)

    # Fields mirroring 'User' table since all our Profiles are not local
    name = models.CharField(_("Name"), blank=True, max_length=255)
    email = models.EmailField(_("email address"), blank=True)

    # GUID
    guid = models.CharField(_("GUID"),
                            max_length=255,
                            unique=True,
                            editable=False)

    # Globally unique handle in format [email protected]
    handle = models.CharField(_("Handle"),
                              editable=False,
                              max_length=255,
                              unique=True,
                              validators=[validate_email])

    # RSA key
    rsa_private_key = models.TextField(_("RSA private key"),
                                       null=True,
                                       editable=False)
    rsa_public_key = models.TextField(_("RSA public key"),
                                      null=True,
                                      editable=False)

    # Profile visibility
    visibility = EnumIntegerField(Visibility,
                                  verbose_name=_("Profile visibility"),
                                  default=Visibility.SELF)

    # Image urls
    image_url_large = models.URLField(_("Image - large"), blank=True)
    image_url_medium = models.URLField(_("Image - medium"), blank=True)
    image_url_small = models.URLField(_("Image - small"), blank=True)

    # Location
    location = models.CharField(_("Location"), max_length=128, blank=True)

    # NSFW status
    nsfw = models.BooleanField(
        _("NSFW"),
        default=False,
        help_text=_("Should users content be considered NSFW?"))

    # Following
    following = models.ManyToManyField("self",
                                       verbose_name=_("Following"),
                                       related_name="followers",
                                       symmetrical=False)

    objects = ProfileQuerySet.as_manager()

    def __str__(self):
        return "%s (%s)" % (self.name, self.handle)

    def get_absolute_url(self):
        return reverse("users:profile-detail", kwargs={"guid": self.guid})

    def save(self, *args, **kwargs):
        """Set default pony images if image urls are empty."""
        if not self.image_url_small or not self.image_url_medium or not self.image_url_large:
            ponies = get_pony_urls()
            for idx, attr in enumerate(
                ["image_url_large", "image_url_medium", "image_url_small"]):
                if not getattr(self, attr, None):
                    setattr(self, attr, ponies[idx])
        super().save(*args, **kwargs)

    @property
    def home_url(self):
        if not self.user:
            # TODO: this is basically "diaspora" - support other networks too by looking at where user came from
            return self.remote_url
        return self.get_absolute_url()

    @property
    def remote_url(self):
        return "https://%s/people/%s" % (self.handle.split("@")[1], self.guid)

    def generate_new_rsa_key(self):
        """Generate a new RSA private key

        Also cache the public key for faster retrieval into own field.
        """
        key = generate_rsa_private_key()
        self.rsa_public_key = key.publickey().exportKey()
        self.rsa_private_key = key.exportKey()
        self.save(update_fields=("rsa_private_key", "rsa_public_key"))

    @cached_property
    def private_key(self):
        """Required by federation.

        Corresponds to private key.
        """
        return RSA.importKey(self.rsa_private_key)

    @cached_property
    def key(self):
        """Required by federation.

        Corresponds to public key.
        """
        return RSA.importKey(self.rsa_public_key)

    @property
    def public(self):
        """Is this profile public or one of the more limited visibilities?"""
        return self.visibility == Visibility.PUBLIC

    def safer_image_url(self, size):
        """Return a most likely more working image url for the profile.

        Some urls are proven to be relative to host instead of absolute urls.
        """
        attr = "image_url_%s" % size
        if getattr(self, attr).startswith("/"):
            return "https://%s%s" % (
                self.handle.split("@")[1],
                getattr(self, attr),
            )
        return getattr(self, attr)

    @property
    def safer_image_url_small(self):
        return self.safer_image_url("small")

    @property
    def safer_image_url_medium(self):
        return self.safer_image_url("medium")

    @property
    def safer_image_url_large(self):
        return self.safer_image_url("large")

    @cached_property
    def following_ids(self):
        return self.following.values_list("id", flat=True)

    def visible_to_user(self, user):
        """Check whether the given user should be able to see this profile."""
        if self.visibility == Visibility.PUBLIC:
            return True
        elif user.is_authenticated:
            if self.visibility == Visibility.SITE or user.profile == self:
                return True
        # TODO: handle Visibility.LIMITED once contacts are implemented
        return False

    def get_first_name(self):
        """Return User.first_name or part of Profile.name"""
        if self.user and self.user.first_name:
            return self.user.first_name
        elif self.name:
            return self.name.split(" ")[0]
        return ""

    def get_last_name(self):
        """Return User.last_name or part of Profile.name"""
        if self.user and self.user.last_name:
            return self.user.last_name
        elif self.name:
            try:
                return self.name.split(" ", 1)[1]
            except IndexError:
                return ""
        return ""

    @staticmethod
    def from_remote_profile(remote_profile):
        """Create a Profile from a remote Profile entity."""
        profile, _created = Profile.objects.update_or_create(
            guid=safe_text(remote_profile.guid),
            handle=safe_text(remote_profile.handle),
            defaults={
                "name":
                safe_text(remote_profile.name),
                "visibility":
                Visibility.PUBLIC
                if remote_profile.public else Visibility.LIMITED,
                "rsa_public_key":
                safe_text(remote_profile.public_key),
                "image_url_large":
                safe_text(remote_profile.image_urls["large"]),
                "image_url_medium":
                safe_text(remote_profile.image_urls["medium"]),
                "image_url_small":
                safe_text(remote_profile.image_urls["small"]),
                "location":
                safe_text(remote_profile.location),
                "email":
                safe_text(remote_profile.email),
            },
        )
        return profile
예제 #22
0
class Attribute(TranslatableModel):
    identifier = InternalIdentifierField(unique=True,
                                         blank=False,
                                         null=False,
                                         editable=True)
    searchable = models.BooleanField(
        default=True,
        verbose_name=_("searchable"),
        help_text=
        _("Searchable attributes will be used for product lookup when customers search your store."
          ))
    type = EnumIntegerField(
        AttributeType,
        default=AttributeType.TRANSLATED_STRING,
        verbose_name=_("type"),
        help_text=
        _("The attribute data type. Attribute values can be set on the product editor page."
          ))
    visibility_mode = EnumIntegerField(
        AttributeVisibility,
        default=AttributeVisibility.SHOW_ON_PRODUCT_PAGE,
        verbose_name=_("visibility mode"),
        help_text=_(
            "Select the attribute visibility setting. "
            "Attributes can be shown on the product detail page or can be used to enhance product search results."
        ))

    translations = TranslatedFields(name=models.CharField(
        max_length=64,
        verbose_name=_("name"),
        help_text=
        _("The attribute name. "
          "Product attributes can be used to list the various features of a product and can be shown on the "
          "product detail page. The product attributes for a product are determined by the product type and can "
          "be set on the product editor page.")), )

    objects = AttributeQuerySet.as_manager()

    class Meta:
        verbose_name = _('attribute')
        verbose_name_plural = _('attributes')

    def __str__(self):
        return u'%s' % self.name

    def save(self, *args, **kwargs):
        if not self.identifier:
            raise ValueError(u"Attribute with null identifier not allowed")
        self.identifier = flatten(("%s" % self.identifier).lower())
        return super(Attribute, self).save(*args, **kwargs)

    def formfield(self, **kwargs):
        """
        Get a form field for this attribute.

        :param kwargs: Kwargs to pass for the form field class.
        :return: Form field.
        :rtype: forms.Field
        """
        kwargs.setdefault("required", False)
        kwargs.setdefault(
            "label", self.safe_translation_getter("name", self.identifier))
        if self.type == AttributeType.INTEGER:
            return forms.IntegerField(**kwargs)
        elif self.type == AttributeType.DECIMAL:
            return forms.DecimalField(**kwargs)
        elif self.type == AttributeType.BOOLEAN:
            return forms.NullBooleanField(**kwargs)
        elif self.type == AttributeType.TIMEDELTA:
            kwargs.setdefault("help_text", "(as seconds)")
            # TODO: This should be more user friendly
            return forms.DecimalField(**kwargs)
        elif self.type == AttributeType.DATETIME:
            return forms.DateTimeField(**kwargs)
        elif self.type == AttributeType.DATE:
            return forms.DateField(**kwargs)
        elif self.type == AttributeType.UNTRANSLATED_STRING:
            return forms.CharField(**kwargs)
        elif self.type == AttributeType.TRANSLATED_STRING:
            # Note: this isn't enough for actually saving multi-language entries;
            #       the caller will have to deal with calling this function several
            #       times for that.
            return forms.CharField(**kwargs)
        else:
            raise ValueError("`formfield` can't deal with fields of type %r" %
                             self.type)

    @property
    def is_translated(self):
        return (self.type == AttributeType.TRANSLATED_STRING)

    @property
    def is_stringy(self):
        # Pun intended.
        return (self.type in ATTRIBUTE_STRING_TYPES)

    @property
    def is_numeric(self):
        return (self.type in ATTRIBUTE_NUMERIC_TYPES)

    @property
    def is_temporal(self):
        return (self.type in ATTRIBUTE_DATETIME_TYPES)

    def is_null_value(self, value):
        """
        Find out whether the given value is null from this attribute's point of view.

        :param value: A value
        :type value: object
        :return: Nulliness boolean
        :rtype: bool
        """
        if self.type == AttributeType.BOOLEAN:
            return (value is None)
        return (not value)
예제 #23
0
class OrderStatus(TranslatableModel):
    identifier = InternalIdentifierField(
        db_index=True,
        blank=False,
        editable=True,
        unique=True,
        help_text=
        _("Internal identifier for status. This is used to identify and distinguish the statuses in Shuup."
          ),
    )
    ordering = models.IntegerField(
        db_index=True,
        default=0,
        verbose_name=_("ordering"),
        help_text=
        _("The processing order of statuses. Default is always processed first."
          ),
    )
    role = EnumIntegerField(
        OrderStatusRole,
        db_index=True,
        default=OrderStatusRole.NONE,
        verbose_name=_("role"),
        help_text=
        _("The role of this status. One role can have multiple order statuses."
          ),
    )
    default = models.BooleanField(
        default=False,
        db_index=True,
        verbose_name=_("default"),
        help_text=
        _("Defines if the status should be considered as default. Default is always processed first."
          ),
    )

    is_active = models.BooleanField(
        default=True,
        db_index=True,
        verbose_name=_("is active"),
        help_text=_("Defines if the status is usable."))

    objects = OrderStatusQuerySet.as_manager()

    translations = TranslatedFields(
        name=models.CharField(verbose_name=_("name"),
                              max_length=64,
                              help_text=_("Name of the order status.")),
        public_name=models.CharField(
            verbose_name=_("public name"),
            max_length=64,
            help_text=_("The name shown to the customers in shop front.")),
    )

    class Meta:
        unique_together = ("identifier", "role")
        verbose_name = _("order status")
        verbose_name_plural = _("order statuses")

    def __str__(self):
        return force_text(
            self.safe_translation_getter("name", default=self.identifier))

    def save(self, *args, **kwargs):
        super(OrderStatus, self).save(*args, **kwargs)
        if self.default and self.role != OrderStatusRole.NONE:
            # If this status is the default, make the others for this role non-default.
            OrderStatus.objects.filter(role=self.role).exclude(
                pk=self.pk).update(default=False)
예제 #24
0
class Category(MPTTModel, TranslatableModel):
    parent = TreeForeignKey('self',
                            null=True,
                            blank=True,
                            related_name='children',
                            verbose_name=_('parent category'))
    shops = models.ManyToManyField("Shop",
                                   blank=True,
                                   related_name="categories")
    identifier = InternalIdentifierField()
    status = EnumIntegerField(CategoryStatus,
                              db_index=True,
                              verbose_name=_('status'),
                              default=CategoryStatus.INVISIBLE)
    image = FilerImageField(verbose_name=_('image'), blank=True, null=True)
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'))
    visibility = EnumIntegerField(CategoryVisibility,
                                  db_index=True,
                                  default=CategoryVisibility.VISIBLE_TO_ALL,
                                  verbose_name=_('visibility limitations'))
    visibility_groups = models.ManyToManyField(
        "ContactGroup",
        blank=True,
        verbose_name=_('visible for groups'),
        related_name=u"visible_categories")

    translations = TranslatedFields(
        name=models.CharField(max_length=128, verbose_name=_('name')),
        description=models.TextField(verbose_name=_('description'),
                                     blank=True),
        slug=models.SlugField(blank=True, null=True, verbose_name=_('slug')))

    objects = CategoryManager()

    class Meta:
        verbose_name = _('category')
        verbose_name_plural = _('categories')

    class MPTTMeta:
        order_insertion_by = ["ordering"]

    def __str__(self):
        return self.safe_translation_getter("name", any_language=True)

    def is_visible(self, customer):
        if customer and customer.is_all_seeing:
            return (self.status != CategoryStatus.DELETED)
        if self.status != CategoryStatus.VISIBLE:
            return False
        if not customer or customer.is_anonymous:
            if self.visibility != CategoryVisibility.VISIBLE_TO_ALL:
                return False
        else:
            if self.visibility == CategoryVisibility.VISIBLE_TO_GROUPS:
                group_ids = customer.groups.all().values_list("id", flat=True)
                return self.visibility_groups.filter(id__in=group_ids).exists()
        return True

    @staticmethod
    def _get_slug_name(self):
        if self.status == CategoryStatus.DELETED:
            return None
        return self.safe_translation_getter("name")

    def save(self, *args, **kwargs):
        rv = super(Category, self).save(*args, **kwargs)
        generate_multilanguage_slugs(self, self._get_slug_name)
        return rv
예제 #25
0
class ProductMedia(TranslatableModel):
    identifier = InternalIdentifierField(unique=True)
    product = models.ForeignKey("Product", related_name="media", on_delete=models.CASCADE, verbose_name=_('product'))
    shops = models.ManyToManyField("Shop", related_name="product_media", verbose_name=_('shops'), help_text=_(
            "Select which shops you would like the product media to be visible in."
        )
    )
    kind = EnumIntegerField(
        ProductMediaKind, db_index=True, default=ProductMediaKind.GENERIC_FILE, verbose_name=_('kind'), help_text=_(
            "Select what type the media is. It can either be a normal file, part of the documentation, or a sample."
        )
    )
    file = FilerFileField(blank=True, null=True, verbose_name=_('file'), on_delete=models.CASCADE)
    external_url = models.URLField(
        blank=True, null=True, verbose_name=_('URL'),
        help_text=_("Enter URL to external file. If this field is filled, the selected media doesn't apply.")
    )
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'), help_text=_(
            "You can assign numerical values to images to tell the order in which they "
            "shall be displayed on the product page."
        )
    )
    # Status
    enabled = models.BooleanField(db_index=True, default=True, verbose_name=_("enabled"))
    public = models.BooleanField(
        default=True, blank=True, verbose_name=_('public (shown on product page)'), help_text=_(
            "Enable this if you want this image be shown on the product page. Enabled by default."
        )
    )
    purchased = models.BooleanField(
        default=False, blank=True, verbose_name=_('purchased (shown for finished purchases)'), help_text=_(
            "Enable this if you want the product media to be shown for completed purchases."
        )
    )

    translations = TranslatedFields(
        title=models.CharField(blank=True, max_length=128, verbose_name=_('title'), help_text=_(
                "Choose a title for your product media. This will help it be found in your store and on the web."
            )
        ),
        description=models.TextField(blank=True, verbose_name=_('description'), help_text=_(
                "Write a description for your product media. This will help it be found in your store and on the web."
            )
        ),
    )

    class Meta:
        verbose_name = _('product attachment')
        verbose_name_plural = _('product attachments')
        ordering = ["ordering", ]

    def __str__(self):  # pragma: no cover
        return self.effective_title

    @property
    def effective_title(self):
        title = self.safe_translation_getter("title")
        if title:
            return title

        if self.file_id:
            return self.file.label

        if self.external_url:
            return self.external_url

        return _('attachment')

    @property
    def url(self):
        if self.external_url:
            return self.external_url
        if self.file:
            return self.file.url
        return ""

    @property
    def easy_thumbnails_thumbnailer(self):
        """
        Get `Thumbnailer` instance.

        Will return `None` if file cannot be thumbnailed.

        :rtype:easy_thumbnails.files.Thumbnailer|None
        """
        if not self.file_id:
            return None

        if self.kind != ProductMediaKind.IMAGE:
            return None

        return get_thumbnailer(self.file)

    def get_thumbnail(self, **kwargs):
        """
        Get thumbnail for image.

        This will return `None` if there is no file or kind is not `ProductMediaKind.IMAGE`

        :rtype: easy_thumbnails.files.ThumbnailFile|None
        """
        kwargs.setdefault("size", (64, 64))
        kwargs.setdefault("crop", True)  # sane defaults
        kwargs.setdefault("upscale", True)  # sane defaults

        if kwargs["size"] is (0, 0):
            return None

        thumbnailer = self.easy_thumbnails_thumbnailer

        if not thumbnailer:
            return None

        try:
            return thumbnailer.get_thumbnail(thumbnail_options=kwargs)
        except InvalidImageFormatError:
            return None
예제 #26
0
class Slide(TranslatableShuupModel):
    carousel = models.ForeignKey(Carousel,
                                 related_name="slides",
                                 on_delete=models.CASCADE)
    name = models.CharField(
        max_length=50,
        blank=True,
        null=True,
        verbose_name=_("name"),
        help_text=_("Name is only used to configure slides."))
    product_link = models.ForeignKey(
        Product,
        related_name="+",
        blank=True,
        null=True,
        verbose_name=_("product link"),
        help_text=
        _("Set the product detail page that should be shown when this slide is clicked, if any."
          ))
    category_link = models.ForeignKey(
        Category,
        related_name="+",
        blank=True,
        null=True,
        verbose_name=_("category link"),
        help_text=
        _("Set the product category page that should be shown when this slide is clicked, if any."
          ))
    cms_page_link = models.ForeignKey(
        Page,
        related_name="+",
        verbose_name=_("cms page link"),
        blank=True,
        null=True,
        help_text=
        _("Set the web page that should be shown when the slide is clicked, if any."
          ))
    ordering = models.IntegerField(
        default=0,
        blank=True,
        null=True,
        verbose_name=_("ordering"),
        help_text=
        _("Set the numeric order in which this slide should appear relative to other slides in this carousel."
          ))
    target = EnumIntegerField(
        LinkTargetType,
        default=LinkTargetType.CURRENT,
        verbose_name=_("link target"),
        help_text=
        _("Set this to current if clicking on this slide should open a new browser tab."
          ))
    available_from = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_('available from'),
        help_text=
        _("Set the date and time from which this slide should be visible in the carousel. "
          "This is useful to advertise sales campaigns or other time-sensitive marketing."
          ))
    available_to = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_('available to'),
        help_text=
        _("Set the date and time from which this slide should be visible in the carousel. "
          "This is useful to advertise sales campaigns or other time-sensitive marketing."
          ))

    translations = TranslatedFields(
        caption=models.CharField(
            max_length=80,
            blank=True,
            null=True,
            verbose_name=_("caption"),
            help_text=
            _("Text that describes the image. Used for search engine purposes."
              )),
        caption_text=models.TextField(
            blank=True,
            null=True,
            verbose_name=_("caption text"),
            help_text=
            _("When displayed in banner box mode, caption text is shown as a tooltip"
              ),
        ),
        external_link=models.CharField(
            max_length=160,
            blank=True,
            null=True,
            verbose_name=_("external link"),
            help_text=
            _("Set the external site that should be shown when this slide is clicked, if any."
              )),
        image=FilerImageField(blank=True,
                              null=True,
                              related_name="+",
                              verbose_name=_("image"),
                              on_delete=models.PROTECT,
                              help_text=_("The slide image to show.")))

    def __str__(self):
        return "%s %s" % (_("Slide"), self.pk)

    class Meta:
        verbose_name = _("Slide")
        verbose_name_plural = _("Slides")
        ordering = ("ordering", "id")

    def get_translated_field(self, attr):
        if not self.safe_translation_getter(attr):
            return self.safe_translation_getter(
                attr, language_code=settings.PARLER_DEFAULT_LANGUAGE_CODE)
        return getattr(self, attr)

    def get_link_url(self):
        """
        Get right link url for this slide.

        Initially external link is used. If not set link will fallback to
        product_link, external_link or cms_page_link in this order.

        :return: return correct link url for slide if set
        :rtype: str|None
        """
        external_link = self.get_translated_field("external_link")
        if external_link:
            return external_link
        elif self.product_link:
            return reverse("shuup:product",
                           kwargs=dict(pk=self.product_link.pk,
                                       slug=self.product_link.slug))
        elif self.category_link:
            return reverse("shuup:category",
                           kwargs=dict(pk=self.category_link.pk,
                                       slug=self.category_link.slug))
        elif self.cms_page_link:
            return reverse("shuup:cms_page",
                           kwargs=dict(url=self.cms_page_link.url))

    def is_visible(self, dt=None):
        """
        Get slides that should be publicly visible.

        This does not do permission checking.

        :param dt: Datetime for visibility check
        :type dt: datetime.datetime
        :return: Public visibility status
        :rtype: bool
        """
        if not dt:
            dt = now()

        return ((self.available_from and self.available_from <= dt)
                and (self.available_to is None or self.available_to >= dt))

    def get_link_target(self):
        """
        Return link target type string based on selection

        :return: Target type string
        :rtype: str
        """
        if self.target == LinkTargetType.NEW:
            return "_blank"
        else:
            return "_self"

    @property
    def easy_thumbnails_thumbnailer(self):
        """
        Get Thumbnailer instance for the translated image.
        Will return None if file cannot be thumbnailed.
        :rtype:easy_thumbnails.files.Thumbnailer|None
        """
        image = self.get_translated_field("image")

        if not image:
            return

        try:
            return get_thumbnailer(image)
        except ValueError:
            return get_thumbnailer(image.filer_image_file)
        except:
            return None

    def get_thumbnail(self, **kwargs):
        """
        Get thumbnail for the translated image
        This will return None if there is no file
        :rtype: easy_thumbnails.files.ThumbnailFile|None
        """
        kwargs.setdefault(
            "size", (self.carousel.image_width, self.carousel.image_height))
        kwargs.setdefault("crop", True)  # sane defaults
        kwargs.setdefault("upscale", True)  # sane defaults

        if kwargs["size"] is (0, 0):
            return None

        thumbnailer = self.easy_thumbnails_thumbnailer

        if not thumbnailer:
            return None

        return thumbnailer.get_thumbnail(thumbnail_options=kwargs)

    objects = SlideQuerySet.as_manager()
예제 #27
0
class ShopProduct(MoneyPropped, models.Model):
    shop = models.ForeignKey("Shop",
                             related_name="shop_products",
                             on_delete=models.CASCADE,
                             verbose_name=_("shop"))
    product = UnsavedForeignKey("Product",
                                related_name="shop_products",
                                on_delete=models.CASCADE,
                                verbose_name=_("product"))
    suppliers = models.ManyToManyField(
        "Supplier",
        related_name="shop_products",
        blank=True,
        verbose_name=_("suppliers"),
        help_text=
        _("List your suppliers here. Suppliers can be found in Product Settings - Suppliers."
          ))

    visibility = EnumIntegerField(
        ShopProductVisibility,
        default=ShopProductVisibility.ALWAYS_VISIBLE,
        db_index=True,
        verbose_name=_("visibility"),
        help_text=mark_safe_lazy(
            _("Select if you want your product to be seen and found by customers. "
              "<p>Not visible: Product will not be shown in your store front or found in search.</p>"
              "<p>Searchable: Product will be shown in search but not listed on any category page.</p>"
              "<p>Listed: Product will be shown on category pages but not shown in search results.</p>"
              "<p>Always Visible: Product will be shown in your store front and found in search.</p>"
              )))
    purchasable = models.BooleanField(default=True,
                                      db_index=True,
                                      verbose_name=_("purchasable"))
    visibility_limit = EnumIntegerField(
        ProductVisibility,
        db_index=True,
        default=ProductVisibility.VISIBLE_TO_ALL,
        verbose_name=_('visibility limitations'),
        help_text=
        _("Select whether you want your product to have special limitations on its visibility in your store. "
          "You can make products visible to all, visible to only logged in users, or visible only to certain "
          "customer groups."))
    visibility_groups = models.ManyToManyField(
        "ContactGroup",
        related_name='visible_products',
        verbose_name=_('visible for groups'),
        blank=True,
        help_text=
        _(u"Select the groups you would like to make your product visible for. "
          u"These groups are defined in Contacts Settings - Contact Groups."))
    backorder_maximum = QuantityField(
        default=0,
        blank=True,
        null=True,
        verbose_name=_('backorder maximum'),
        help_text=_(
            "The number of units that can be purchased after the product is out of stock. "
            "Set to blank for product to be purchasable without limits."))
    purchase_multiple = QuantityField(
        default=0,
        verbose_name=_('purchase multiple'),
        help_text=_(
            "Set this if the product needs to be purchased in multiples. "
            "For example, if the purchase multiple is set to 2, then customers are required to order the product "
            "in multiples of 2."))
    minimum_purchase_quantity = QuantityField(
        default=1,
        verbose_name=_('minimum purchase'),
        help_text=_(
            "Set a minimum number of products needed to be ordered for the purchase. "
            "This is useful for setting bulk orders and B2B purchases."))
    limit_shipping_methods = models.BooleanField(
        default=False,
        verbose_name=_("limited for shipping methods"),
        help_text=_(
            "Check this if you want to limit your product to use only select payment methods. "
            "You can select the payment method(s) in the field below."))
    limit_payment_methods = models.BooleanField(
        default=False,
        verbose_name=_("limited for payment methods"),
        help_text=_(
            "Check this if you want to limit your product to use only select payment methods. "
            "You can select the payment method(s) in the field below."))
    shipping_methods = models.ManyToManyField(
        "ShippingMethod",
        related_name='shipping_products',
        verbose_name=_('shipping methods'),
        blank=True,
        help_text=_(
            "Select the shipping methods you would like to limit the product to using. "
            "These are defined in Settings - Shipping Methods."))
    payment_methods = models.ManyToManyField(
        "PaymentMethod",
        related_name='payment_products',
        verbose_name=_('payment methods'),
        blank=True,
        help_text=_(
            "Select the payment methods you would like to limit the product to using. "
            "These are defined in Settings - Payment Methods."))
    primary_category = models.ForeignKey(
        "Category",
        related_name='primary_shop_products',
        verbose_name=_('primary category'),
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        help_text=_(
            "Choose the primary category for your product. "
            "This will be the main category for classification in the system. "
            "Your product can be found under this category in your store. "
            "Categories are defined in Products Settings - Categories."))
    categories = models.ManyToManyField(
        "Category",
        related_name='shop_products',
        verbose_name=_('categories'),
        blank=True,
        help_text=_(
            "Add secondary categories for your product. "
            "These are other categories that your product fits under and that it can be found by in your store."
        ))
    shop_primary_image = models.ForeignKey(
        "ProductMedia",
        null=True,
        blank=True,
        related_name="primary_image_for_shop_products",
        on_delete=models.SET_NULL,
        verbose_name=_("primary image"),
        help_text=
        _("Click this to set this image as the primary display image for your product."
          ))

    # the default price of this product in the shop
    default_price = PriceProperty('default_price_value', 'shop.currency',
                                  'shop.prices_include_tax')
    default_price_value = MoneyValueField(
        verbose_name=_("default price"),
        null=True,
        blank=True,
        help_text=_(
            "This is the default individual base unit (or multi-pack) price of the product. "
            "All discounts or coupons will be based off of this price."))

    minimum_price = PriceProperty('minimum_price_value', 'shop.currency',
                                  'shop.prices_include_tax')
    minimum_price_value = MoneyValueField(
        verbose_name=_("minimum price"),
        null=True,
        blank=True,
        help_text=
        _("This is the default price that the product cannot go under in your store, "
          "despite coupons or discounts being applied. "
          "This is useful to make sure your product price stays above cost."))

    display_unit = models.ForeignKey(
        DisplayUnit,
        null=True,
        blank=True,
        verbose_name=_("display unit"),
        help_text=_("Unit for displaying quantities of this product"))

    class Meta:
        unique_together = ((
            "shop",
            "product",
        ), )

    def save(self, *args, **kwargs):
        self.clean()
        super(ShopProduct, self).save(*args, **kwargs)
        for supplier in self.suppliers.all():
            supplier.module.update_stock(product_id=self.product.id)

    def clean(self):
        super(ShopProduct, self).clean()
        if self.display_unit:
            if self.display_unit.internal_unit != self.product.sales_unit:
                raise ValidationError({
                    'display_unit':
                    _("Invalid display unit: Internal unit of "
                      "the selected display unit does not match "
                      "with the sales unit of the product")
                })

    def is_list_visible(self):
        """
        Return True if this product should be visible in listings in general,
        without taking into account any other visibility limitations.
        :rtype: bool
        """
        if self.product.deleted:
            return False
        if not self.listed:
            return False
        if self.product.is_variation_child():
            return False
        return True

    @property
    def primary_image(self):
        if self.shop_primary_image_id:
            return self.shop_primary_image
        else:
            return self.product.primary_image

    @property
    def searchable(self):
        return self.visibility in (ShopProductVisibility.SEARCHABLE,
                                   ShopProductVisibility.ALWAYS_VISIBLE)

    @property
    def listed(self):
        return self.visibility in (ShopProductVisibility.LISTED,
                                   ShopProductVisibility.ALWAYS_VISIBLE)

    @property
    def visible(self):
        return not (self.visibility == ShopProductVisibility.NOT_VISIBLE)

    @property
    def public_primary_image(self):
        primary_image = self.primary_image
        return primary_image if primary_image and primary_image.public else None

    def get_visibility_errors(self, customer):
        if self.product.deleted:
            yield ValidationError(_('This product has been deleted.'),
                                  code="product_deleted")

        if customer and customer.is_all_seeing:  # None of the further conditions matter for omniscient customers.
            return

        if not self.visible:
            yield ValidationError(_('This product is not visible.'),
                                  code="product_not_visible")

        is_logged_in = (bool(customer) and not customer.is_anonymous)

        if not is_logged_in and self.visibility_limit != ProductVisibility.VISIBLE_TO_ALL:
            yield ValidationError(
                _('The Product is invisible to users not logged in.'),
                code="product_not_visible_to_anonymous")

        if is_logged_in and self.visibility_limit == ProductVisibility.VISIBLE_TO_GROUPS:
            # TODO: Optimization
            user_groups = set(customer.groups.all().values_list("pk",
                                                                flat=True))
            my_groups = set(self.visibility_groups.values_list("pk",
                                                               flat=True))
            if not bool(user_groups & my_groups):
                yield ValidationError(
                    _('This product is not visible to your group.'),
                    code="product_not_visible_to_group")

        for receiver, response in get_visibility_errors.send(
                ShopProduct, shop_product=self, customer=customer):
            for error in response:
                yield error

    # TODO: Refactor get_orderability_errors, it's too complex
    def get_orderability_errors(  # noqa (C901)
            self,
            supplier,
            quantity,
            customer,
            ignore_minimum=False):
        """
        Yield ValidationErrors that would cause this product to not be orderable.

        :param supplier: Supplier to order this product from. May be None.
        :type supplier: shuup.core.models.Supplier
        :param quantity: Quantity to order.
        :type quantity: int|Decimal
        :param customer: Customer contact.
        :type customer: shuup.core.models.Contact
        :param ignore_minimum: Ignore any limitations caused by quantity minimums.
        :type ignore_minimum: bool
        :return: Iterable[ValidationError]
        """
        for error in self.get_visibility_errors(customer):
            yield error

        if supplier is None and not self.suppliers.exists():
            # `ShopProduct` must have at least one `Supplier`.
            # If supplier is not given and the `ShopProduct` itself
            # doesn't have suppliers we cannot sell this product.
            yield ValidationError(_('The product has no supplier.'),
                                  code="no_supplier")

        if not ignore_minimum and quantity < self.minimum_purchase_quantity:
            yield ValidationError(_(
                'The purchase quantity needs to be at least %d for this product.'
            ) % self.minimum_purchase_quantity,
                                  code="purchase_quantity_not_met")

        if supplier and not self.suppliers.filter(pk=supplier.pk).exists():
            yield ValidationError(_('The product is not supplied by %s.') %
                                  supplier,
                                  code="invalid_supplier")

        if self.product.mode == ProductMode.SIMPLE_VARIATION_PARENT:
            sellable = False
            for child_product in self.product.variation_children.all():
                child_shop_product = child_product.get_shop_instance(self.shop)
                if child_shop_product.is_orderable(
                        supplier=supplier,
                        customer=customer,
                        quantity=child_shop_product.minimum_purchase_quantity,
                        allow_cache=False):
                    sellable = True
                    break
            if not sellable:
                yield ValidationError(_("Product has no sellable children"),
                                      code="no_sellable_children")
        elif self.product.mode == ProductMode.VARIABLE_VARIATION_PARENT:
            from shuup.core.models import ProductVariationResult
            sellable = False
            for combo in self.product.get_all_available_combinations():
                res = ProductVariationResult.resolve(
                    self.product, combo["variable_to_value"])
                if not res:
                    continue
                child_shop_product = res.get_shop_instance(self.shop)
                if child_shop_product.is_orderable(
                        supplier=supplier,
                        customer=customer,
                        quantity=child_shop_product.minimum_purchase_quantity,
                        allow_cache=False):
                    sellable = True
                    break
            if not sellable:
                yield ValidationError(_("Product has no sellable children"),
                                      code="no_sellable_children")

        if self.product.is_package_parent():
            for child_product, child_quantity in six.iteritems(
                    self.product.get_package_child_to_quantity_map()):
                try:
                    child_shop_product = child_product.get_shop_instance(
                        shop=self.shop, allow_cache=False)
                except ShopProduct.DoesNotExist:
                    yield ValidationError("%s: Not available in %s" %
                                          (child_product, self.shop),
                                          code="invalid_shop")
                else:
                    for error in child_shop_product.get_orderability_errors(
                            supplier=supplier,
                            quantity=(quantity * child_quantity),
                            customer=customer,
                            ignore_minimum=ignore_minimum):
                        message = getattr(error, "message", "")
                        code = getattr(error, "code", None)
                        yield ValidationError("%s: %s" %
                                              (child_product, message),
                                              code=code)

        if supplier and self.product.stock_behavior == StockBehavior.STOCKED:
            for error in supplier.get_orderability_errors(self,
                                                          quantity,
                                                          customer=customer):
                yield error

        purchase_multiple = self.purchase_multiple
        if quantity > 0 and purchase_multiple > 0 and (quantity %
                                                       purchase_multiple) != 0:
            p = (quantity // purchase_multiple)
            smaller_p = max(purchase_multiple, p * purchase_multiple)
            larger_p = max(purchase_multiple, (p + 1) * purchase_multiple)
            render_qty = self.unit.render_quantity
            if larger_p == smaller_p:
                message = _("The product can only be ordered in multiples of "
                            "{package_size}, for example {amount}").format(
                                package_size=render_qty(purchase_multiple),
                                amount=render_qty(smaller_p))
            else:
                message = _("The product can only be ordered in multiples of "
                            "{package_size}, for example {smaller_amount} or "
                            "{larger_amount}").format(
                                package_size=render_qty(purchase_multiple),
                                smaller_amount=render_qty(smaller_p),
                                larger_amount=render_qty(larger_p))
            yield ValidationError(message, code="invalid_purchase_multiple")

        for receiver, response in get_orderability_errors.send(
                ShopProduct,
                shop_product=self,
                customer=customer,
                supplier=supplier,
                quantity=quantity):
            for error in response:
                yield error

    def raise_if_not_orderable(self,
                               supplier,
                               customer,
                               quantity,
                               ignore_minimum=False):
        for message in self.get_orderability_errors(
                supplier=supplier,
                quantity=quantity,
                customer=customer,
                ignore_minimum=ignore_minimum):
            raise ProductNotOrderableProblem(message.args[0])

    def raise_if_not_visible(self, customer):
        for message in self.get_visibility_errors(customer=customer):
            raise ProductNotVisibleProblem(message.args[0])

    def is_orderable(self, supplier, customer, quantity, allow_cache=True):
        key, val = context_cache.get_cached_value(
            identifier="is_orderable",
            item=self,
            context={"customer": customer},
            supplier=supplier,
            quantity=quantity,
            allow_cache=allow_cache)
        if customer and val is not None:
            return val

        if not supplier:
            supplier = self.suppliers.first()  # TODO: Allow multiple suppliers
        for message in self.get_orderability_errors(supplier=supplier,
                                                    quantity=quantity,
                                                    customer=customer):
            if customer:
                context_cache.set_cached_value(key, False)
            return False

        if customer:
            context_cache.set_cached_value(key, True)
        return True

    def is_visible(self, customer):
        for message in self.get_visibility_errors(customer=customer):
            return False
        return True

    @property
    def quantity_step(self):
        """
        Quantity step for purchasing this product.

        :rtype: decimal.Decimal

        Example:
            <input type="number" step="{{ shop_product.quantity_step }}">
        """
        step = self.purchase_multiple or self._sales_unit.quantity_step
        return self._sales_unit.round(step)

    @property
    def rounded_minimum_purchase_quantity(self):
        """
        The minimum purchase quantity, rounded to the sales unit's precision.

        :rtype: decimal.Decimal

        Example:
            <input type="number"
                min="{{ shop_product.rounded_minimum_purchase_quantity }}"
                value="{{ shop_product.rounded_minimum_purchase_quantity }}">

        """
        return self._sales_unit.round(self.minimum_purchase_quantity)

    @property
    def display_quantity_step(self):
        """
        Quantity step of this shop product in the display unit.

        Note: This can never be smaller than the display precision.
        """
        return max(self.unit.to_display(self.quantity_step),
                   self.unit.display_precision)

    @property
    def display_quantity_minimum(self):
        """
        Quantity minimum of this shop product in the display unit.

        Note: This can never be smaller than the display precision.
        """
        return max(self.unit.to_display(self.minimum_purchase_quantity),
                   self.unit.display_precision)

    @property
    def unit(self):
        """
        Unit of this product.

        :rtype: shuup.core.models.UnitInterface
        """
        return UnitInterface(self._sales_unit, self.display_unit)

    @property
    def _sales_unit(self):
        return self.product.sales_unit or PiecesSalesUnit()

    @property
    def images(self):
        return self.product.media.filter(
            shops=self.shop, kind=ProductMediaKind.IMAGE).order_by("ordering")

    @property
    def public_images(self):
        return self.images.filter(public=True)
예제 #28
0
class Product(AttributableMixin, TranslatableModel):

    COMMON_SELECT_RELATED = ("type", "primary_image", "tax_class")

    # Metadata
    created_on = models.DateTimeField(auto_now_add=True, editable=False)
    modified_on = models.DateTimeField(auto_now=True, editable=False)
    deleted = models.BooleanField(default=False, editable=False, db_index=True)

    # Behavior
    mode = EnumIntegerField(ProductMode, default=ProductMode.NORMAL)
    variation_parent = models.ForeignKey(
        "self", null=True, blank=True, related_name='variation_children',
        on_delete=models.PROTECT,
        verbose_name=_('variation parent'))
    stock_behavior = EnumIntegerField(StockBehavior, default=StockBehavior.UNSTOCKED, verbose_name=_('stock'))
    shipping_mode = EnumIntegerField(ShippingMode, default=ShippingMode.NOT_SHIPPED, verbose_name=_('shipping mode'))
    sales_unit = models.ForeignKey("SalesUnit", verbose_name=_('unit'), blank=True, null=True)
    tax_class = models.ForeignKey("TaxClass", verbose_name=_('tax class'))

    # Identification
    type = models.ForeignKey(
        "ProductType", related_name='products',
        on_delete=models.PROTECT, db_index=True,
        verbose_name=_('product type'))
    sku = models.CharField(db_index=True, max_length=128, verbose_name=_('SKU'), unique=True)
    gtin = models.CharField(blank=True, max_length=40, verbose_name=_('GTIN'), help_text=_('Global Trade Item Number'))
    barcode = models.CharField(blank=True, max_length=40, verbose_name=_('barcode'))
    accounting_identifier = models.CharField(max_length=32, blank=True, verbose_name=_('bookkeeping account'))
    profit_center = models.CharField(max_length=32, verbose_name=_('profit center'), blank=True)
    cost_center = models.CharField(max_length=32, verbose_name=_('cost center'), blank=True)
    # Category is duplicated here because not all products necessarily belong in Shops (i.e. have
    # ShopProduct instances), but they should nevertheless be searchable by category in other
    # places, such as administration UIs.
    category = models.ForeignKey(
        "Category", related_name='primary_products', blank=True, null=True,
        verbose_name=_('primary category'),
        help_text=_("only used for administration and reporting"))

    # Physical dimensions
    width = MeasurementField(unit="mm", verbose_name=_('width (mm)'))
    height = MeasurementField(unit="mm", verbose_name=_('height (mm)'))
    depth = MeasurementField(unit="mm", verbose_name=_('depth (mm)'))
    net_weight = MeasurementField(unit="g", verbose_name=_('net weight (g)'))
    gross_weight = MeasurementField(unit="g", verbose_name=_('gross weight (g)'))

    # Misc.
    purchase_price = MoneyField(verbose_name=_('purchase price'))
    suggested_retail_price = MoneyField(verbose_name=_('suggested retail price'))
    manufacturer = models.ForeignKey("Manufacturer", blank=True, null=True, verbose_name=_('manufacturer'))
    primary_image = models.ForeignKey(
        "ProductMedia", null=True, blank=True,
        related_name="primary_image_for_products",
        on_delete=models.SET_NULL)

    translations = TranslatedFields(
        name=models.CharField(max_length=256, verbose_name=_('name')),
        description=models.TextField(blank=True, verbose_name=_('description')),
        slug=models.SlugField(verbose_name=_('slug'), max_length=255, null=True),
        keywords=models.TextField(blank=True, verbose_name=_('keywords')),
        status_text=models.CharField(
            max_length=128, blank=True,
            verbose_name=_('status text'),
            help_text=_(
                'This text will be shown alongside the product in the shop.'
                ' (Ex.: "Available in a month")')),
        variation_name=models.CharField(
            max_length=128, blank=True,
            verbose_name=_('variation name'))
    )

    objects = ProductQuerySet.as_manager()

    class Meta:
        ordering = ('-id',)
        verbose_name = _('product')
        verbose_name_plural = _('products')

    def __str__(self):
        try:
            return u"%s" % self.name
        except ObjectDoesNotExist:
            return self.sku

    def get_shop_instance(self, shop):
        """
        :type shop: shoop.core.models.shops.Shop
        :rtype: shoop.core.models.product_shops.ShopProduct
        """
        shop_inst_cache = self.__dict__.setdefault("_shop_inst_cache", {})
        cached = shop_inst_cache.get(shop)
        if cached:
            return cached

        shop_inst = self.shop_products.filter(shop=shop).first()
        if shop_inst:
            shop_inst._product_cache = self
            shop_inst._shop_cache = shop
            shop_inst_cache[shop] = shop_inst

        return shop_inst

    def get_cheapest_child_price(self, context, quantity=1):
        return sorted(
            c.get_price(context, quantity=quantity)
            for c in self.variation_children.all()
        )[0]

    def get_price(self, context, quantity=1):
        """
        :type context: shoop.core.contexts.PriceTaxContext
        :rtype: shoop.core.pricing.Price
        """
        from shoop.core.pricing import get_pricing_module
        module = get_pricing_module()
        pricing_context = module.get_context(context)
        return module.get_price(pricing_context, product_id=self.pk, quantity=quantity)

    def get_base_price(self):
        from shoop.core.pricing import get_pricing_module
        module = get_pricing_module()
        return module.get_base_price(product_id=self.pk)

    def get_taxed_price(self, context, quantity=1):
        """
        :type context: shoop.core.contexts.PriceTaxContext
        :rtype: shoop.core.pricing.TaxedPrice
        """
        from shoop.core import taxing
        module = taxing.get_tax_module()
        return module.determine_product_tax(context, self)

    def get_available_attribute_queryset(self):
        if self.type_id:
            return self.type.attributes.visible()
        else:
            return Attribute.objects.none()

    @staticmethod
    def _get_slug_name(self):
        if self.deleted:
            return None
        return (self.safe_translation_getter("name") or self.sku)

    def save(self, *args, **kwargs):
        if self.net_weight and self.net_weight > 0:
            self.gross_weight = max(self.net_weight, self.gross_weight)
        rv = super(Product, self).save(*args, **kwargs)
        generate_multilanguage_slugs(self, self._get_slug_name)
        return rv

    def delete(self, using=None):
        raise NotImplementedError("Not implemented: Use `soft_delete()` for products.")

    def soft_delete(self, user=None):
        if not self.deleted:
            self.deleted = True
            self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION, user=user)
            # Bypassing local `save()` on purpose.
            super(Product, self).save(update_fields=("deleted",))

    def verify_mode(self):
        if ProductPackageLink.objects.filter(parent=self).count():
            self.mode = ProductMode.PACKAGE_PARENT
            self.external_url = None
            self.variation_children.clear()
        elif self.variation_children.count():
            if ProductVariationResult.objects.filter(product=self).count():
                self.mode = ProductMode.VARIABLE_VARIATION_PARENT
            else:
                self.mode = ProductMode.SIMPLE_VARIATION_PARENT
            self.external_url = None
            ProductPackageLink.objects.filter(parent=self).delete()
        elif self.variation_parent:
            self.mode = ProductMode.VARIATION_CHILD
            ProductPackageLink.objects.filter(parent=self).delete()
            self.variation_children.clear()
            self.external_url = None
        else:
            self.mode = ProductMode.NORMAL

    def unlink_from_parent(self):
        if self.variation_parent:
            parent = self.variation_parent
            self.variation_parent = None
            self.save()
            parent.verify_mode()
            self.verify_mode()
            self.save()
            ProductVariationResult.objects.filter(result=self).delete()
            return True

    def link_to_parent(self, parent, variables=None):
        if parent.mode == ProductMode.VARIATION_CHILD:
            raise ValueError("Multilevel parentage hierarchies aren't supported (parent is a child already)")
        if parent.mode == ProductMode.VARIABLE_VARIATION_PARENT and not variables:
            raise ValueError("Parent is a variable variation parent, yet variables were not passed to `link_to_parent`")
        if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT and variables:
            raise ValueError("Parent is a simple variation parent, yet variables were passed to `link_to_parent`")
        if self.mode == ProductMode.SIMPLE_VARIATION_PARENT:
            raise ValueError(
                "Multilevel parentage hierarchies aren't supported (this product is a simple variation parent)"
            )
        if self.mode == ProductMode.VARIABLE_VARIATION_PARENT:
            raise ValueError(
                "Multilevel parentage hierarchies aren't supported (this product is a variable variation parent)"
            )

        self.unlink_from_parent()
        self.variation_parent = parent
        self.verify_mode()
        self.save()
        if parent.mode not in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT):
            parent.verify_mode()
            parent.save()

        if variables:
            mapping = {}
            for variable_identifier, value_identifier in variables.items():
                variable_identifier, _ = ProductVariationVariable.objects.get_or_create(
                    product=parent, identifier=variable_identifier
                )
                value_identifier, _ = ProductVariationVariableValue.objects.get_or_create(
                    variable=variable_identifier, identifier=value_identifier
                )
                mapping[variable_identifier] = value_identifier
            pvr = ProductVariationResult.objects.create(
                product=parent,
                combination_hash=hash_combination(mapping),
                result=self
            )
            if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT:
                parent.verify_mode()
                parent.save()
            return pvr
        else:
            return True

    def make_package(self, package_def):
        if self.mode != ProductMode.NORMAL:
            raise ValueError("Product is currently not a normal product, can't turn into package")

        for child_product, quantity in six.iteritems(package_def):
            if child_product.mode in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT):
                raise ValueError("Variation parents can not belong into a package")
            if child_product.mode == ProductMode.PACKAGE_PARENT:
                raise ValueError("Can't nest packages")

            if quantity <= 0:
                raise ValueError("Quantity %s is invalid" % quantity)
            ProductPackageLink.objects.create(parent=self, child=child_product, quantity=quantity)
        self.verify_mode()

    def get_package_child_to_quantity_map(self):
        if self.mode == ProductMode.PACKAGE_PARENT:
            product_id_to_quantity = dict(
                ProductPackageLink.objects.filter(parent=self).values_list("child_id", "quantity")
            )
            products = dict((p.pk, p) for p in Product.objects.filter(pk__in=product_id_to_quantity.keys()))
            return {products[product_id]: quantity for (product_id, quantity) in six.iteritems(product_id_to_quantity)}
        return {}
예제 #29
0
class Supplier(ModuleInterface, ShuupModel):
    default_module_spec = "shuup.core.suppliers:BaseSupplierModule"
    module_provides_key = "supplier_module"

    identifier = InternalIdentifierField(unique=True)
    name = models.CharField(
        verbose_name=_("name"),
        max_length=64,
        help_text=_(
            "The product suppliers name. "
            "Suppliers can be used manage the inventory of stocked products."))
    type = EnumIntegerField(
        SupplierType,
        verbose_name=_("supplier type"),
        default=SupplierType.INTERNAL,
        help_text=_(
            "The supplier type indicates whether the products are supplied through an internal supplier or "
            "an external supplier."))
    stock_managed = models.BooleanField(
        verbose_name=_("stock managed"),
        default=False,
        help_text=
        _("Check this if this supplier will be used to manage the inventory of stocked products."
          ))
    module_identifier = models.CharField(
        max_length=64,
        blank=True,
        verbose_name=_('module'),
        help_text=_(
            "Select the supplier module to use for this supplier. "
            "Supplier modules define the rules by which inventory is managed.")
    )
    module_data = JSONField(blank=True,
                            null=True,
                            verbose_name=_("module data"))
    shops = models.ManyToManyField(
        "Shop",
        blank=True,
        related_name="suppliers",
        verbose_name=_("shops"),
        help_text=_(
            "You can select which shops the supplier is available to."))

    def __str__(self):
        return self.name

    def get_orderability_errors(self, shop_product, quantity, customer):
        """
        :param shop_product: Shop Product
        :type shop_product: shuup.core.models.ShopProduct
        :param quantity: Quantity to order
        :type quantity: decimal.Decimal
        :param contect: Ordering contact.
        :type contect: shuup.core.models.Contact
        :rtype: iterable[ValidationError]
        """
        return self.module.get_orderability_errors(shop_product=shop_product,
                                                   quantity=quantity,
                                                   customer=customer)

    def get_stock_statuses(self, product_ids):
        """
        :param product_ids: Iterable of product IDs
        :return: Dict of {product_id: ProductStockStatus}
        :rtype: dict[int, shuup.core.stocks.ProductStockStatus]
        """
        return self.module.get_stock_statuses(product_ids)

    def get_stock_status(self, product_id):
        """
        :param product_id: Product ID
        :type product_id: int
        :rtype: shuup.core.stocks.ProductStockStatus
        """
        return self.module.get_stock_status(product_id)

    def get_suppliable_products(self, shop, customer):
        """
        :param shop: Shop to check for suppliability
        :type shop: shuup.core.models.Shop
        :param customer: Customer contact to check for suppliability
        :type customer: shuup.core.models.Contact
        :rtype: list[int]
        """
        return [
            shop_product.pk
            for shop_product in self.shop_products.filter(shop=shop)
            if shop_product.is_orderable(
                self, customer, shop_product.minimum_purchase_quantity)
        ]

    def adjust_stock(self, product_id, delta, created_by=None, type=None):
        from shuup.core.suppliers.base import StockAdjustmentType
        adjustment_type = type or StockAdjustmentType.INVENTORY
        return self.module.adjust_stock(product_id,
                                        delta,
                                        created_by=created_by,
                                        type=adjustment_type)

    def update_stock(self, product_id):
        return self.module.update_stock(product_id)

    def update_stocks(self, product_ids):
        return self.module.update_stocks(product_ids)
예제 #30
0
class Colored(models.Model):
    color = EnumIntegerField(Color, default=Color.RED)

    class Meta:
        app_label = 'colored_test'