Skip to content
16 changes: 14 additions & 2 deletions backend/apps/github/api/internal/nodes/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@
"avatar_url",
"bio",
"company",
"contributions_count",
"email",
"followers_count",
"following_count",
"id",
"is_owasp_staff",
"location",
"login",
"name",
Expand All @@ -28,6 +26,13 @@
class UserNode:
"""GitHub user node."""

@strawberry.field
def contributions_count(self) -> int:
"""Resolve contributions count."""
if hasattr(self, "owasp_profile"):
return self.owasp_profile.contributions_count
return 0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should ensure backward compatibility. You should return the User old field contribution_count if you found that contribution_count of owasp_profile is the default value i.e. 0.


@strawberry.field
def badge_count(self) -> int:
"""Resolve badge count."""
Expand Down Expand Up @@ -83,6 +88,13 @@ def is_gsoc_mentor(self) -> bool:
return self.owasp_profile.is_gsoc_mentor
return False

@strawberry.field
def is_owasp_staff(self) -> bool:
"""Resolve if the user is an OWASP staff member."""
if hasattr(self, "owasp_profile"):
return self.owasp_profile.is_owasp_staff
return False

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above. Return the old field.

@strawberry.field
def issues_count(self) -> int:
"""Resolve issues count."""
Expand Down
6 changes: 5 additions & 1 deletion backend/apps/github/api/internal/queries/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,8 @@ def user(
User or None: The user object if found, otherwise None.

"""
return User.objects.filter(has_public_member_page=True, login=login).first()
return (
User.objects.select_related("owasp_profile")
.filter(owasp_profile__has_public_member_page=True, login=login)
.first()
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

22 changes: 16 additions & 6 deletions backend/apps/github/management/commands/github_update_users.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command was originally made for updating the GitHub.User contributions count. So, I suggest to move this command to Owasp app and only update the contributions count of MemberProfile.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you asked, I created a new member profile update in the owasp folder only updating contributions_count,
so far a few changes are still pending. I’ll have this PR ready for review soon. (I have exams going on, so my time is a bit limited, but I’ll do my best to finish this in the next 24 hour)

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from apps.common.models import BATCH_SIZE
from apps.github.models.repository_contributor import RepositoryContributor
from apps.github.models.user import User
from apps.owasp.models.member_profile import MemberProfile

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -45,15 +46,24 @@ def handle(self, *args, **options):
.values("user_id")
.annotate(total_contributions=Sum("contributions_count"))
}
users = []
profiles = []
for idx, user in enumerate(active_users[offset:]):
prefix = f"{idx + offset + 1} of {active_users_count - offset}"
print(f"{prefix:<10} {user.title}")

user.contributions_count = user_contributions.get(user.id, 0)
users.append(user)
profile, created = MemberProfile.objects.get_or_create(github_user=user)
if created:
profile.github_user = user
profile.contributions_count = user_contributions.get(user.id, 0)
profiles.append(profile)

if not len(users) % BATCH_SIZE:
User.bulk_save(users, fields=("contributions_count",))
if not len(profiles) % BATCH_SIZE:
MemberProfile.bulk_save(
profiles,
fields=("contributions_count",),
)

User.bulk_save(users, fields=("contributions_count",))
MemberProfile.bulk_save(
profiles,
fields=("contributions_count",),
)
2 changes: 1 addition & 1 deletion backend/apps/github/models/mixins/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def idx_contributions(self):
@property
def idx_contributions_count(self) -> int:
"""Return contributions count for indexing."""
return self.contributions_count
return self.owasp_profile.contributions_count

@property
def idx_issues(self) -> list[dict]:
Expand Down
4 changes: 3 additions & 1 deletion backend/apps/nest/api/internal/nodes/user.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here

Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ class AuthUserNode(strawberry.relay.Node):
@strawberry_django.field
def is_owasp_staff(self) -> bool:
"""Check if the user is an OWASP staff member."""
return self.github_user.is_owasp_staff if self.github_user else False
if self.github_user and hasattr(self.github_user, "owasp_profile"):
return self.github_user.owasp_profile.is_owasp_staff
return False
4 changes: 2 additions & 2 deletions backend/apps/nest/management/commands/nest_update_badges.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def update_owasp_staff_badge(self):

# Assign badge to employees who don't have it.
employees_without_badge = User.objects.filter(
is_owasp_staff=True,
owasp_profile__is_owasp_staff=True,
).exclude(
user_badges__badge=badge,
)
Expand All @@ -60,7 +60,7 @@ def update_owasp_staff_badge(self):

# Remove badge from non-OWASP employees.
non_employees = User.objects.filter(
is_owasp_staff=False,
owasp_profile__is_owasp_staff=False,
user_badges__badge=badge,
).distinct()
removed_count = non_employees.count()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here

Expand Down
12 changes: 11 additions & 1 deletion backend/apps/owasp/admin/member_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class MemberProfileAdmin(admin.ModelAdmin):
autocomplete_fields = ("github_user",)
list_display = (
"github_user",
"is_owasp_staff",
"has_public_member_page",
"contributions_count",
"owasp_slack_id",
"first_contribution_at",
"is_owasp_board_member",
Expand Down Expand Up @@ -47,7 +50,14 @@ class MemberProfileAdmin(admin.ModelAdmin):
),
(
"Contribution Information",
{"fields": ("first_contribution_at",)},
{
"fields": (
"first_contribution_at",
"is_owasp_staff",
"has_public_member_page",
"contributions_count",
)
},
),
(
"Membership Flags",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ class HasDashboardAccess(BasePermission):

def has_permission(self, source, info, **kwargs) -> bool:
"""Check if the user has dashboard access."""
user = info.context.request.user
if not (user and user.is_authenticated and user.github_user):
return False

return (
(user := info.context.request.user)
and user.is_authenticated
and user.github_user.is_owasp_staff
hasattr(user.github_user, "owasp_profile")
and user.github_user.owasp_profile.is_owasp_staff
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

)
9 changes: 8 additions & 1 deletion backend/apps/owasp/api/internal/views/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@

def has_dashboard_permission(request):
"""Check if user has dashboard access."""
return (user := request.user) and user.is_authenticated and user.github_user.is_owasp_staff
user = request.user
if not (user and user.is_authenticated and user.github_user):
return False

return (
hasattr(user.github_user, "owasp_profile")
and user.github_user.owasp_profile.is_owasp_staff
)


def dashboard_access_required(view_func):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2025-11-18 17:59

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("owasp", "0065_memberprofile_linkedin_page_id"),
]

operations = [
migrations.AddField(
model_name="memberprofile",
name="contributions_count",
field=models.PositiveIntegerField(default=0, verbose_name="Contributions count"),
),
migrations.AddField(
model_name="memberprofile",
name="has_public_member_page",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="memberprofile",
name="is_owasp_staff",
field=models.BooleanField(
default=False,
help_text="Indicates if the user is OWASP Foundation staff.",
verbose_name="Is OWASP Staff",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 5.2.7 on 2025-11-18 18:04

from django.db import migrations


def copy_user_data_to_member_profile(apps, _schema_editor):
"""Copy user data to member profile."""
User = apps.get_model("github", "User")
MemberProfile = apps.get_model("owasp", "MemberProfile")
profiles_to_update = []
batch_size = 500
update_fields = [
"has_public_member_page",
"is_owasp_staff",
"contributions_count",
]

for user in User.objects.all().iterator(chunk_size=batch_size):
profile, _ = MemberProfile.objects.get_or_create(github_user=user)
profile.has_public_member_page = user.has_public_member_page
profile.is_owasp_staff = user.is_owasp_staff
profile.contributions_count = user.contributions_count
profiles_to_update.append(profile)

if len(profiles_to_update) >= batch_size:
MemberProfile.objects.bulk_update(
profiles_to_update, update_fields, batch_size=batch_size
)
profiles_to_update = []

if profiles_to_update:
MemberProfile.objects.bulk_update(profiles_to_update, update_fields, batch_size=batch_size)


class Migration(migrations.Migration):
dependencies = [
("owasp", "0066_memberprofile_contributions_count_and_more"),
("github", "0039_remove_commit_commit_repo_created_idx"),
]

operations = [
migrations.RunPython(copy_user_data_to_member_profile, migrations.RunPython.noop),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-26 14:23

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('owasp', '0067_memberprofile_backward_compatibility'),
]

operations = [
migrations.AlterField(
model_name='memberprofile',
name='has_public_member_page',
field=models.BooleanField(default=True, help_text="Whether the member's profile is publicly visible on the OWASP website", verbose_name='Has Public Member Page'),
),
]
21 changes: 20 additions & 1 deletion backend/apps/owasp/models/member_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.core.validators import RegexValidator
from django.db import models

from apps.common.models import TimestampedModel
from apps.common.models import BulkSaveModel, TimestampedModel
from apps.github.models.user import User


Expand Down Expand Up @@ -71,6 +71,25 @@ class Meta:
help_text="LinkedIn username or custom URL ID (e.g., 'john-doe-123')",
)

has_public_member_page = models.BooleanField(
default=True,
verbose_name="Has Public Member Page",
help_text="Whether the member's profile is publicly visible on the OWASP website",
)
is_owasp_staff = models.BooleanField(
default=False,
verbose_name="Is OWASP Staff",
help_text="Indicates if the user is OWASP Foundation staff.",
)
contributions_count = models.PositiveIntegerField(
verbose_name="Contributions count", default=0
)

def __str__(self) -> str:
"""Return human-readable representation."""
return f"OWASP member profile for {self.github_user.login}"

@staticmethod
def bulk_save(profiles, fields=None) -> None:
"""Bulk save member profiles."""
BulkSaveModel.bulk_save(MemberProfile, profiles, fields=fields)
50 changes: 31 additions & 19 deletions backend/tests/apps/github/api/internal/queries/user_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,61 @@ def mock_user(self):

def test_resolve_user_existing_with_public_member_page(self, mock_user):
"""Test resolving an existing user with has_public_member_page=True."""
with patch("apps.github.models.user.User.objects.filter") as mock_filter:
mock_queryset = mock_filter.return_value
mock_queryset.first.return_value = mock_user
with patch("apps.github.models.user.User.objects.select_related") as mock_select_related:
mock_queryset = mock_select_related.return_value
mock_queryset.filter.return_value.first.return_value = mock_user

result = UserQuery().user(login="test-user")

assert result == mock_user
mock_filter.assert_called_once_with(has_public_member_page=True, login="test-user")
mock_queryset.first.assert_called_once()
mock_select_related.assert_called_once_with("owasp_profile")
mock_queryset.filter.assert_called_once_with(
owasp_profile__has_public_member_page=True, login="test-user"
)
mock_queryset.filter.return_value.first.assert_called_once()

def test_resolve_user_not_found_when_has_public_member_page_false(self):
"""Test resolving a user with has_public_member_page=False returns None."""
with patch("apps.github.models.user.User.objects.filter") as mock_filter:
mock_queryset = mock_filter.return_value
mock_queryset.first.return_value = None
with patch("apps.github.models.user.User.objects.select_related") as mock_select_related:
mock_queryset = mock_select_related.return_value
mock_queryset.filter.return_value.first.return_value = None

result = UserQuery().user(login="test-user")

assert result is None
mock_filter.assert_called_once_with(has_public_member_page=True, login="test-user")
mock_queryset.first.assert_called_once()
mock_select_related.assert_called_once_with("owasp_profile")
mock_queryset.filter.assert_called_once_with(
owasp_profile__has_public_member_page=True, login="test-user"
)
mock_queryset.filter.return_value.first.assert_called_once()

def test_resolve_user_not_found(self):
"""Test resolving a non-existent user."""
with patch("apps.github.models.user.User.objects.filter") as mock_filter:
mock_queryset = mock_filter.return_value
mock_queryset.first.return_value = None
with patch("apps.github.models.user.User.objects.select_related") as mock_select_related:
mock_queryset = mock_select_related.return_value
mock_queryset.filter.return_value.first.return_value = None

result = UserQuery().user(login="non-existent")

assert result is None
mock_filter.assert_called_once_with(has_public_member_page=True, login="non-existent")
mock_queryset.first.assert_called_once()
mock_select_related.assert_called_once_with("owasp_profile")
mock_queryset.filter.assert_called_once_with(
owasp_profile__has_public_member_page=True, login="non-existent"
)
mock_queryset.filter.return_value.first.assert_called_once()

def test_resolve_user_filters_by_public_member_page_and_login(self):
"""Test that user query filters by both has_public_member_page and login."""
with patch("apps.github.models.user.User.objects.filter") as mock_filter:
mock_queryset = mock_filter.return_value
mock_queryset.first.return_value = None
with patch("apps.github.models.user.User.objects.select_related") as mock_select_related:
mock_queryset = mock_select_related.return_value
mock_queryset.filter.return_value.first.return_value = None

UserQuery().user(login="test-user")

mock_filter.assert_called_once_with(has_public_member_page=True, login="test-user")
mock_select_related.assert_called_once_with("owasp_profile")
mock_queryset.filter.assert_called_once_with(
owasp_profile__has_public_member_page=True, login="test-user"
)

def test_top_contributed_repositories(self):
"""Test resolving top contributed repositories."""
Expand Down
Loading