Skip to content

Commit

Permalink
Merge branch 'main' into feature/delete-attachment
Browse files Browse the repository at this point in the history
  • Loading branch information
magicznyleszek authored Feb 14, 2025
2 parents b0e472e + 74cfec4 commit 0f31d94
Show file tree
Hide file tree
Showing 8 changed files with 275 additions and 104 deletions.
16 changes: 6 additions & 10 deletions jsapp/js/account/addOns/addOnList.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,10 @@ const AddOnList = (props: {
<tr className={styles.row} key={oneTimeAddOn.id}>
<td className={styles.product}>
<span className={styles.productName}>
{t('##name## x ##quantity##')
.replace(
'##name##',
oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.name || label,
)
.replace('##quantity##', oneTimeAddOn.quantity.toString())}
{t('##name##').replace(
'##name##',
oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.name || label,
)}
</span>
<Badge color={color} size={'s'} label={badgeLabel} />
<p className={styles.addonDescription}>
Expand All @@ -119,10 +117,8 @@ const AddOnList = (props: {
{'$##price##'.replace(
'##price##',
(
(oneTimeAddOn.quantity *
(oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.prices[0]
.unit_amount || 0)) /
100
(oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.prices[0]
.unit_amount || 0) / 100
).toFixed(2),
)}
</td>
Expand Down
30 changes: 4 additions & 26 deletions jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,6 @@ interface OneTimeAddOnRowProps {
organization: Organization
}

const MAX_ONE_TIME_ADDON_PURCHASE_QUANTITY = 10

const quantityOptions = Array.from({ length: MAX_ONE_TIME_ADDON_PURCHASE_QUANTITY }, (_, zeroBasedIndex) => {
const index = (zeroBasedIndex + 1).toString()
return { value: index, label: index }
})

export const OneTimeAddOnRow = ({
products,
isBusy,
Expand All @@ -33,9 +26,8 @@ export const OneTimeAddOnRow = ({
organization,
}: OneTimeAddOnRowProps) => {
const [selectedProduct, setSelectedProduct] = useState(products[0])
const [quantity, setQuantity] = useState('1')
const [selectedPrice, setSelectedPrice] = useState<Product['prices'][0]>(selectedProduct.prices[0])
const displayPrice = useDisplayPrice(selectedPrice, parseInt(quantity))
const displayPrice = useDisplayPrice(selectedPrice)
const priceOptions = useMemo(
() =>
selectedProduct.prices.map((price) => {
Expand Down Expand Up @@ -79,12 +71,6 @@ export const OneTimeAddOnRow = ({
}
}

const onChangeQuantity = (quantity: string | null) => {
if (quantity) {
setQuantity(quantity)
}
}

// TODO: Merge functionality of onClickBuy and onClickManage so we can unduplicate
// the billing button in priceTableCells
const onClickBuy = () => {
Expand All @@ -93,7 +79,7 @@ export const OneTimeAddOnRow = ({
}
setIsBusy(true)
if (selectedPrice) {
postCheckout(selectedPrice.id, organization.id, parseInt(quantity))
postCheckout(selectedPrice.id, organization.id)
.then((response) => window.location.assign(response.url))
.catch(() => setIsBusy(false))
}
Expand Down Expand Up @@ -147,30 +133,22 @@ export const OneTimeAddOnRow = ({
<td className={styles.price}>
<div className={styles.oneTime}>
<KoboSelect3
size='m'
size={'fit'}
name='products'
options={products.map((product) => {
return { value: product.id, label: product.name }
})}
onChange={(productId) => onChangeProduct(productId as string)}
value={selectedProduct.id}
/>
{displayName === 'File Storage' ? (
{displayName === 'File Storage' && (
<KoboSelect3
size={'fit'}
name={t('prices')}
options={priceOptions}
onChange={onChangePrice}
value={selectedPrice.id}
/>
) : (
<KoboSelect3
size={'fit'}
name={t('quantity')}
options={quantityOptions}
onChange={onChangeQuantity}
value={quantity}
/>
)}
</div>
</td>
Expand Down
1 change: 0 additions & 1 deletion jsapp/js/account/security/securityRoute.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ h2.securityHeaderText {

.securityHeaderActions {
@include mixins.centerRowFlex;
padding-right: 15px;
}

// Shared styles for sections
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ function OneTimeAddOnList(props: OneTimeAddOnList) {
return {
productName,
remainingLimit,
quantity: addon.quantity,
}
}),
[props.oneTimeAddOns, props.type, productsContext.isLoaded],
Expand All @@ -49,7 +48,6 @@ function OneTimeAddOnList(props: OneTimeAddOnList) {
<div className={styles.oneTimeAddOnListEntry} key={i}>
<label className={styles.productName}>
<span>{addon.productName}</span>
{addon.quantity > 1 && <span>&nbsp;x {addon.quantity}</span>}
</label>
<div>
{t('##REMAINING## remaining').replace('##REMAINING##', limitDisplay(props.type, addon.remainingLimit))}
Expand Down
2 changes: 2 additions & 0 deletions jsapp/js/components/special/koboAccessibleSelect.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ $input-color: colors.$kobo-gray-800;
width: 100%;
position: relative;
font-size: 12px;
padding-left: 5px;
padding-right: 5px;
}

.m {
Expand Down
7 changes: 5 additions & 2 deletions kobo/apps/organizations/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@
ORG_MEMBER_ROLE = 'member'
ORG_OWNER_ROLE = 'owner'
USER_DOES_NOT_EXIST_ERROR = (
'User with username or email {invitee} does not exist or is not active.'
'User with username or email `##invitee##` does not exist or is not active.'
)
INVITE_ALREADY_EXISTS_ERROR = (
'An active invitation already exists for ##invitee##'
'An active invitation already exists for `##invitee##`'
)
INVITEE_ALREADY_MEMBER_ERROR = (
'User is already a member of this organization.'
)
116 changes: 75 additions & 41 deletions kobo/apps/organizations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext as t
from rest_framework import serializers
Expand Down Expand Up @@ -36,7 +35,8 @@
USER_DOES_NOT_EXIST_ERROR,
INVITE_ALREADY_ACCEPTED_ERROR,
INVITE_NOT_FOUND_ERROR,
INVITE_ALREADY_EXISTS_ERROR
INVITE_ALREADY_EXISTS_ERROR,
INVITEE_ALREADY_MEMBER_ERROR
)
from .tasks import transfer_member_data_ownership_to_org

Expand Down Expand Up @@ -282,7 +282,7 @@ def create(self, validated_data):
invitees = validated_data['invitees']
role = validated_data['role']
valid_users = invitees['users']
external_emails = invitees['emails']
valid_emails = invitees['emails']

invites = []

Expand All @@ -298,7 +298,7 @@ def create(self, validated_data):
invite.send_invite_email()

# Create invites for external emails
for email in external_emails:
for email in valid_emails:
invite = OrganizationInvitation.objects.create(
invited_by=invited_by,
invitee_identifier=email,
Expand Down Expand Up @@ -392,44 +392,27 @@ def validate_invitees(self, value):
"""
Check if usernames exist in the database, and emails are valid.
"""
valid_users, external_emails = [], []
for idx, invitee in enumerate(value):
valid_users, valid_emails = [], []
organization = self.context['request'].user.organization
for invitee in value:
is_email = self._is_valid_email(invitee)

# Check if the invitee is already invited and has not responded yet
existing_invites = OrganizationInvitation.objects.filter(
Q(invitee__username=invitee) | Q(invitee_identifier=invitee),
organization=self.context['request'].user.organization
).exclude(status__in=['declined', 'expired', 'accepted'])
if existing_invites:
raise serializers.ValidationError(
replace_placeholders(
t(INVITE_ALREADY_EXISTS_ERROR), invitee=invitee
)
)
search_filter = (
{'invitee_identifier': invitee}
if is_email
else {'invitee__username': invitee}
)
self._check_existing_invites(organization, search_filter, invitee)

try:
validate_email(invitee)
users = User.objects.filter(email=invitee)
if users:
user = users.filter(is_active=True).first()
if user:
valid_users.append(user)
else:
raise serializers.ValidationError(
USER_DOES_NOT_EXIST_ERROR.format(invitee=invitee)
)
else:
external_emails.append(invitee)
except ValidationError:
user = User.objects.filter(
username=invitee, is_active=True
).first()
if user:
valid_users.append(user)
else:
raise serializers.ValidationError(
USER_DOES_NOT_EXIST_ERROR.format(invitee=invitee)
)
return {'users': valid_users, 'emails': external_emails}
if is_email:
# Allow multiple invitations for shared email or external users
valid_emails.append(invitee)
else:
user = self._get_valid_user(invitee)
self._check_existing_member(organization, user, invitee)
valid_users.append(user)
return {'users': valid_users, 'emails': valid_emails}

def validate_role(self, value):
if self.instance:
Expand Down Expand Up @@ -530,6 +513,47 @@ def update(self, instance, validated_data):

return instance

def _check_existing_member(self, organization, user, invitee):
"""
Raise an error if the user is already a member of the organization
"""
if OrganizationUser.objects.filter(
organization=organization, user=user
).exists():
raise serializers.ValidationError(
replace_placeholders(
t(INVITEE_ALREADY_MEMBER_ERROR), invitee=invitee
)
)

def _check_existing_invites(self, organization, search_filter, invitee):
"""
Raise an error if an active invitation already exists
"""
if OrganizationInvitation.objects.filter(
**search_filter,
organization=organization,
status=OrganizationInviteStatusChoices.PENDING
).exists():
raise serializers.ValidationError(
replace_placeholders(
t(INVITE_ALREADY_EXISTS_ERROR), invitee=invitee
)
)

def _get_valid_user(self, username):
"""
Fetch a valid user by username, ensuring they exist and are active
"""
user = User.objects.filter(username=username, is_active=True).first()
if not user:
raise serializers.ValidationError(
replace_placeholders(
t(USER_DOES_NOT_EXIST_ERROR), invitee=username
)
)
return user

def _handle_invitee_assignment(self, instance):
"""
Assigns the invitee to the invite after the external user registers
Expand All @@ -538,7 +562,7 @@ def _handle_invitee_assignment(self, instance):
invitee_identifier = instance.invitee_identifier
if invitee_identifier and not instance.invitee:
try:
instance.invitee = User.objects.get(email=invitee_identifier)
instance.invitee = self.context['request'].user
instance.save(update_fields=['invitee'])
except User.DoesNotExist:
raise NotFound({'detail': t(INVITE_NOT_FOUND_ERROR)})
Expand All @@ -551,6 +575,16 @@ def _handle_status_update(self, instance, status):
instance.save(update_fields=['status', 'modified'])
self._send_status_email(instance, status)

def _is_valid_email(self, invitee):
"""
Check if invitee is a valid email
"""
try:
validate_email(invitee)
return True
except ValidationError:
return False

def _send_status_email(self, instance, status):
status_map = {
OrganizationInviteStatusChoices.ACCEPTED:
Expand Down
Loading

0 comments on commit 0f31d94

Please sign in to comment.