diff --git a/.env.example b/.env.example index 994ea9a16..63f34272f 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,8 @@ DATABASE_URL=postgres://user:password@localhost:5432/dbname #Sentry DSN SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 + + +#BlueSky User Details +BLUESKY_USERNAME=example.bsky.social +BLUESKY_PASSWORD='example#123' \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 89c2e3fa0..9c32c2bd5 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -147,6 +147,7 @@ jobs: with: python-version: 3.11.2 - run: pip install poetry + - run: poetry lock --no-update - run: poetry install - run: poetry run python manage.py collectstatic --noinput - name: Run tests diff --git a/blt/settings.py b/blt/settings.py index 73188bd5a..fd2f4392b 100644 --- a/blt/settings.py +++ b/blt/settings.py @@ -125,7 +125,8 @@ "tz_detect.middleware.TimezoneMiddleware", "blt.middleware.ip_restrict.IPRestrictMiddleware", ) - +BLUESKY_USERNAME = env("BLUESKY_USERNAME", default="default_username") +BLUESKY_PASSWORD = env("BLUESKY_PASSWORD", default="default_password") TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" if DEBUG and not TESTING: diff --git a/blt/urls.py b/blt/urls.py index 486185fb6..466311e55 100644 --- a/blt/urls.py +++ b/blt/urls.py @@ -119,15 +119,18 @@ add_role, admin_company_dashboard, admin_company_dashboard_detail, + approve_activity, company_dashboard, company_dashboard_domain_detail, company_dashboard_hunt_detail, company_dashboard_hunt_edit, company_hunt_results, delete_time_entry, + dislike_activity, feed, get_scoreboard, hunt_results, + like_activity, sizzle, sizzle_daily_log, sizzle_docs, @@ -581,6 +584,9 @@ distribute_bacon, name="distribute_bacon", ), + path("activity/like//", like_activity, name="like_activity"), + path("activity/dislike//", dislike_activity, name="dislike_activity"), + path("activity/approve//", approve_activity, name="approve_activity"), re_path(r"^tz_detect/", include("tz_detect.urls")), # re_path(r"^tellme/", include("tellme.urls")), re_path(r"^ratings/", include("star_ratings.urls", namespace="ratings")), diff --git a/pyproject.toml b/pyproject.toml index 81cf0d7eb..626f4ff96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ python-bitcoinrpc = "^1.0" sendgrid = "^6.11.0" matplotlib = "^3.9.2" openpyxl = "^3.1.5" +atproto = "^0.0.55" [tool.poetry.group.dev.dependencies] black = "^24.8.0" diff --git a/website/migrations/0160_activity_dislike_count_activity_dislikes_and_more.py b/website/migrations/0160_activity_dislike_count_activity_dislikes_and_more.py new file mode 100644 index 000000000..fef162d82 --- /dev/null +++ b/website/migrations/0160_activity_dislike_count_activity_dislikes_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 5.1.3 on 2024-11-28 19:23 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0159_merge_20241128_0439"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="activity", + name="dislike_count", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="activity", + name="dislikes", + field=models.ManyToManyField( + blank=True, + related_name="disliked_activities", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="activity", + name="is_approved", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="activity", + name="is_posted_to_bluesky", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="activity", + name="like_count", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="activity", + name="likes", + field=models.ManyToManyField( + blank=True, related_name="liked_activities", to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/website/migrations/0161_merge_20241130_0224.py b/website/migrations/0161_merge_20241130_0224.py new file mode 100644 index 000000000..c7b95e541 --- /dev/null +++ b/website/migrations/0161_merge_20241130_0224.py @@ -0,0 +1,12 @@ +# Generated by Django 5.1.3 on 2024-11-30 02:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0160_activity_dislike_count_activity_dislikes_and_more"), + ("website", "0160_merge_20241129_0712"), + ] + + operations = [] diff --git a/website/migrations/0163_merge_0161_merge_20241130_0224_0162_add_new_badges.py b/website/migrations/0163_merge_0161_merge_20241130_0224_0162_add_new_badges.py new file mode 100644 index 000000000..6c117a9fb --- /dev/null +++ b/website/migrations/0163_merge_0161_merge_20241130_0224_0162_add_new_badges.py @@ -0,0 +1,12 @@ +# Generated by Django 5.1.3 on 2024-12-01 16:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0161_merge_20241130_0224"), + ("website", "0162_add_new_badges"), + ] + + operations = [] diff --git a/website/migrations/0164_merge_20241201_1725.py b/website/migrations/0164_merge_20241201_1725.py new file mode 100644 index 000000000..2e8a32294 --- /dev/null +++ b/website/migrations/0164_merge_20241201_1725.py @@ -0,0 +1,12 @@ +# Generated by Django 5.1.3 on 2024-12-01 17:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0163_merge_0161_merge_20241130_0224_0162_add_new_badges"), + ("website", "0163_merge_0161_merge_20241201_1619_0162_add_new_badges"), + ] + + operations = [] diff --git a/website/models.py b/website/models.py index 3728857a3..c17452ff4 100644 --- a/website/models.py +++ b/website/models.py @@ -943,20 +943,57 @@ class Activity(models.Model): title = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) image = models.ImageField(null=True, blank=True, upload_to="activity_images/") - timestamp = models.DateTimeField(auto_now_add=True) url = models.URLField(null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + # Approval and Posting + like_count = models.PositiveIntegerField(default=0) + dislike_count = models.PositiveIntegerField(default=0) + is_approved = models.BooleanField(default=False) # Whether activity is approved + is_posted_to_bluesky = models.BooleanField(default=False) # Whether posted to BlueSky # Generic foreign key fields content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() related_object = GenericForeignKey("content_type", "object_id") + # New fields for likes and dislikes + likes = models.ManyToManyField(User, related_name="liked_activities", blank=True) + dislikes = models.ManyToManyField(User, related_name="disliked_activities", blank=True) + def __str__(self): return f"{self.title} by {self.user.username} at {self.timestamp}" class Meta: ordering = ["-timestamp"] + # Approve the activity + def approve_activity(self): + # Check auto-approval criteria + if self.like_count >= 3 and self.dislike_count < 3: + self.is_approved = True + self.save() + + # Post to BlueSky + def post_to_bluesky(self, bluesky_service): + if not self.is_approved: + raise ValueError("Activity must be approved before posting to BlueSky.") + + try: + post_data = f"{self.title}\n\n{self.description}" + # If image exists, include it + if self.image: + bluesky_service.post_with_image(text=post_data, image_path=self.image.path) + else: + bluesky_service.post_text(text=post_data) + + # Mark activity as posted + self.is_posted_to_bluesky = True + self.save() + return True + except Exception as e: + print(e) + class Badge(models.Model): BADGE_TYPES = [ diff --git a/website/services/blue_sky_service.py b/website/services/blue_sky_service.py new file mode 100644 index 000000000..53c1d89d9 --- /dev/null +++ b/website/services/blue_sky_service.py @@ -0,0 +1,46 @@ +# services/bluesky_service.py +from atproto import Client, models +from django.conf import settings + + +class BlueSkyService: + def __init__(self): + self.client = Client() + self.client.login(settings.BLUESKY_USERNAME, settings.BLUESKY_PASSWORD) + + def post_text(self, text): + """Post plain text to BlueSky.""" + post = self.client.send_post(text=text) + return post.uri # Assuming the response includes a post ID + + def post_with_image(self, text, image_path): + """Post text with an image to BlueSky.""" + try: + # Read the image as binary + with open(image_path, "rb") as img_file: + img_data = img_file.read() + + # Debug: Confirm image data size, not raw binary + print(f"Uploading image to BlueSky... Size: {len(img_data)} bytes") + + # Upload the image to BlueSky + upload = self.client.upload_blob(img_data) + print(f"Upload response: Blob ID = {upload.blob}") + + # Create the embedded image structure + images = [models.AppBskyEmbedImages.Image(alt="Activity Image", image=upload.blob)] + embed = models.AppBskyEmbedImages.Main(images=images) + print(f"Embed object: {embed}") + + # Create the post record + post_record = models.AppBskyFeedPost.Record( + text=text, embed=embed, created_at=self.client.get_current_time_iso() + ) + + # Post to BlueSky + post = self.client.app.bsky.feed.post.create(self.client.me.did, post_record) + print(f"Post created successfully. URI: {post.uri}") + return post.uri + except Exception as e: + print(f"Error in post_with_image: {e}") + raise diff --git a/website/templates/feed.html b/website/templates/feed.html index 2e6044606..7792fe307 100644 --- a/website/templates/feed.html +++ b/website/templates/feed.html @@ -4,216 +4,291 @@ -
-

Global Activity Feed

-
-
    - {% for activity in page_obj %} -
  • -
    - -
    - {{ activity.user.username }} - {{ activity.timestamp|date:"M d, Y, H:i A" }} -
    - -
    - - {{ activity.get_action_type_display|capfirst }} -
    +

    Global Activity Feed

    +
    +
      + {% for activity in page_obj %} +
    • +
      + +
      + {{ activity.user.username }} + {{ activity.timestamp|date:"M d, Y, H:i A" }} +
      + +
      + + {{ activity.get_action_type_display|capfirst }}
      -
      - -
      -

      {{ activity.title }}

      +
      +
      + +
      +

      {{ activity.title }}

      +
      + + {% if activity.image %} +
      + Activity Image
      - - {% if activity.image %} -
      - Activity Image -
      + {% endif %} + +
      +

      {{ activity.description }}

      +
      + +
      + + + + {% if is_mentor and not activity.is_approved %} + {% endif %} - -
      -

      {{ activity.description }}

      + + {% if activity.is_approved %} +
      + Approved For BlueSky + {% endif %}
    • @@ -239,4 +314,71 @@

      {{ activity.title }}

+ {% endblock %} diff --git a/website/views/organization.py b/website/views/organization.py index 777747c64..ccebf8829 100644 --- a/website/views/organization.py +++ b/website/views/organization.py @@ -44,9 +44,11 @@ Subscription, TimeLog, User, + UserBadge, Wallet, Winner, ) +from website.services.blue_sky_service import BlueSkyService from website.utils import format_timedelta, get_client_ip, get_github_issue_title @@ -1590,11 +1592,123 @@ def feed(request): # Determine if pagination is required is_paginated = page_obj.has_other_pages() + # Check if the user has the mentor badge + if request.user.is_authenticated: + is_mentor = UserBadge.objects.filter(user=request.user, badge__title="Mentor").exists() + else: + is_mentor = False + return render( request, "feed.html", { "page_obj": page_obj, "is_paginated": is_paginated, # Pass this flag to the template + "is_mentor": is_mentor, # Add is_mentor to the context }, ) + + +@login_required +@require_POST +def like_activity(request, id): + activity = get_object_or_404(Activity, id=id) + user = request.user + + if activity.dislikes.filter(id=user.id).exists(): + activity.dislikes.remove(user) + activity.dislike_count -= 1 + + if activity.likes.filter(id=user.id).exists(): + activity.likes.remove(user) + activity.like_count -= 1 + else: + activity.likes.add(user) + activity.like_count += 1 + + activity.save() + + # Check if the activity meets the approval criteria + if activity.like_count >= 3 and activity.dislike_count < 3 and not activity.is_approved: + activity.is_approved = True + activity.save() + + # Trigger posting on BlueSky + blue_sky_service = BlueSkyService() + try: + activity.post_to_bluesky(blue_sky_service) + except Exception: + return JsonResponse({"success": False}) + + return JsonResponse( + { + "success": True, + "like_count": activity.like_count, + "dislike_count": activity.dislike_count, + } + ) + + +@login_required +@require_POST +def dislike_activity(request, id): + activity = get_object_or_404(Activity, id=id) + user = request.user + + if activity.likes.filter(id=user.id).exists(): + activity.likes.remove(user) + activity.like_count -= 1 + + if activity.dislikes.filter(id=user.id).exists(): + activity.dislikes.remove(user) + activity.dislike_count -= 1 + else: + activity.dislikes.add(user) + activity.dislike_count += 1 + + activity.save() + + # Check if the activity meets the approval criteria + if activity.like_count >= 3 and activity.dislike_count < 3 and not activity.is_approved: + activity.is_approved = True + activity.save() + + # Trigger posting on BlueSky + blue_sky_service = BlueSkyService() + try: + activity.post_to_bluesky(blue_sky_service) + except Exception: + return JsonResponse({"success": False}) + + return JsonResponse( + { + "success": True, + "like_count": activity.like_count, + "dislike_count": activity.dislike_count, + } + ) + + +@login_required +@require_POST +def approve_activity(request, id): + activity = get_object_or_404(Activity, id=id) + user = request.user + + # Check if the user has the "Mentor" badge + if ( + UserBadge.objects.filter(user=user, badge__title="Mentor").exists() + and not activity.is_approved + ): + activity.is_approved = True + activity.save() + + # Trigger posting on BlueSky + blue_sky_service = BlueSkyService() + try: + activity.post_to_bluesky(blue_sky_service) + return JsonResponse({"success": True, "is_approved": activity.is_approved}) + except Exception: + return JsonResponse({"success": False}) + else: + return JsonResponse({"success": False, "error": "Not authorized"})