diff --git a/backend/api/consumers.py b/backend/api/consumers.py index 5d658d21e..6f5f66e2d 100644 --- a/backend/api/consumers.py +++ b/backend/api/consumers.py @@ -17,7 +17,7 @@ from lib import cache from lib import channels -from .octoprint_messages import process_octoprint_status +from .octoprint_messages import process_printer_status from app.models import * from lib.tunnelv2 import OctoprintTunnelV2Helper, TunnelAuthenticationError from lib.view_helpers import touch_user_last_active @@ -292,7 +292,7 @@ def receive(self, text_data=None, bytes_data=None, **kwargs): channels.send_message_to_web(self.printer.id, data) else: self.printer.refresh_from_db() - process_octoprint_status(self.printer, data) + process_printer_status(self.printer, data) @newrelic.agent.background_task() @report_error @@ -314,13 +314,20 @@ def close_duplicates(self, data): class JanusWebConsumer(WebsocketConsumer): - def get_printer(self): if 'token' in self.scope['url_route']['kwargs']: return Printer.objects.get( auth_token=self.scope['url_route']['kwargs']['token'], ) + # Mobileraker wants to use tunnel credential to connect janus websocket + try: + pt = OctoprintTunnelV2Helper.get_octoprinttunnel(self.scope) + if pt and str(pt.printer_id) == self.scope['url_route']['kwargs']['printer_id']: + return pt.printer + except TunnelAuthenticationError: + pass # Continue to other ways of authentication + if not self.scope['user'].is_authenticated: raise Printer.DoesNotExist('session is not authenticated') diff --git a/backend/api/octoprint_messages.py b/backend/api/octoprint_messages.py index c7a64dc88..d852c29c4 100644 --- a/backend/api/octoprint_messages.py +++ b/backend/api/octoprint_messages.py @@ -13,14 +13,14 @@ LOGGER = logging.getLogger(__name__) STATUS_TTL_SECONDS = 120 -def process_octoprint_status(printer: Printer, msg: Dict) -> None: +def process_printer_status(printer: Printer, msg: Dict) -> None: # Backward compatibility: octoprint_settings is for OctoPrint-Obico 2.1.2 or earlier, or moonraker-obico 0.5.1 or earlier - octoprint_settings = msg.get('settings') or msg.get('octoprint_settings') - if octoprint_settings: - cache.printer_settings_set(printer.id, settings_dict(octoprint_settings)) + printer_settings = msg.get('settings') or msg.get('octoprint_settings') + if printer_settings: + cache.printer_settings_set(printer.id, settings_dict(printer_settings)) - agent_name = octoprint_settings.get('agent', {}).get('name') - agent_version = octoprint_settings.get('agent', {}).get('version') + agent_name = printer_settings.get('agent', {}).get('name') + agent_version = printer_settings.get('agent', {}).get('version') if agent_name != printer.agent_name or agent_version != printer.agent_version: printer.agent_name = agent_name printer.agent_version = agent_version @@ -53,20 +53,27 @@ def process_octoprint_status(printer: Printer, msg: Dict) -> None: process_heater_temps(printer, temps) -def settings_dict(octoprint_settings): - webcam_settings = dict(Printer.DEFAULT_WEBCAM_SETTINGS) - - webcam_settings.update(octoprint_settings.get('webcam', {})) - settings = dict(('webcam_' + k, str(v)) for k, v in webcam_settings.items()) - - settings.update(dict(temp_profiles=json.dumps(octoprint_settings.get('temperature', {}).get('profiles', [])))) - settings.update(dict(printer_metadata=json.dumps(octoprint_settings.get('printer_metadata', {})))) +def settings_dict(printer_settings): + # Backward compatibility: printer_settings.get('webcam') is for old agent versions, ie, OctoPrint-Obico 2.5.0 or earlier, or moonraker-obico 1.7.0 or earlier + if printer_settings.get('webcam'): + webcam_settings = Printer.DEFAULT_WEBCAM_SETTINGS.copy() + webcam_settings.update(printer_settings.get('webcam')) + webcams = [webcam_settings] + else: + webcams = printer_settings.get('webcams') + + settings = dict() + if webcams is not None: + settings.update(dict(webcams=json.dumps(webcams))) + settings.update(dict(temp_profiles=json.dumps(printer_settings.get('temperature', {}).get('profiles', [])))) + settings.update(dict(printer_metadata=json.dumps(printer_settings.get('printer_metadata', {})))) settings.update( - tsd_plugin_version=octoprint_settings.get('tsd_plugin_version', ''), - octoprint_version=octoprint_settings.get('octoprint_version', ''), + tsd_plugin_version=printer_settings.get('tsd_plugin_version', ''), + octoprint_version=printer_settings.get('octoprint_version', ''), ) - settings.update(dict(platform_uname=json.dumps(octoprint_settings.get('platform_uname', [])))) - settings.update(dict(installed_plugins=json.dumps(octoprint_settings.get('installed_plugins', [])))) + settings.update(dict(platform_uname=json.dumps(printer_settings.get('platform_uname', [])))) + settings.update(dict(installed_plugins=json.dumps(printer_settings.get('installed_plugins', [])))) + return settings diff --git a/backend/api/octoprint_views.py b/backend/api/octoprint_views.py index 319d4cbd0..0b9aa0637 100644 --- a/backend/api/octoprint_views.py +++ b/backend/api/octoprint_views.py @@ -77,6 +77,15 @@ class OctoPrintPicView(APIView): def post(self, request): printer = request.auth + user = request.user + + is_primary_camera = request.POST.get('is_primary_camera', 'true').lower() == 'true' # if not specified, it's from a legacy agent and hence is primary camera + is_nozzle_camera = request.POST.get('is_nozzle_camera', 'false').lower() == 'true' + camera_name = request.POST.get('camera_name', '') # If camera_name is not provided, it's from a legacy agent. + + # TODO: Think about the use cases when non-primary camera sends a pic. For now, we are ignoring it. + if not is_primary_camera: + return Response({'result': 'ok'}) if settings.PIC_POST_LIMIT_PER_MINUTE and cache.pic_post_over_limit(printer.id, settings.PIC_POST_LIMIT_PER_MINUTE): return Response(status=status.HTTP_429_TOO_MANY_REQUESTS) @@ -92,14 +101,14 @@ def post(self, request): if (not printer.current_print) or request.POST.get('viewing_boost'): # Not need for failure detection if not printing, or the pic was send for viewing boost. pic_path = f'snapshots/{printer.id}/latest_unrotated.jpg' - internal_url, external_url = save_file_obj(pic_path, pic, settings.PICS_CONTAINER, long_term_storage=False) + internal_url, external_url = save_file_obj(pic_path, pic, settings.PICS_CONTAINER, user.syndicate.name, long_term_storage=False) cache.printer_pic_set(printer.id, {'img_url': external_url}, ex=IMG_URL_TTL_SECONDS) send_status_to_web(printer.id) return Response({'result': 'ok'}) pic_id = str(timezone.now().timestamp()) pic_path = f'raw/{printer.id}/{printer.current_print.id}/{pic_id}.jpg' - internal_url, external_url = save_file_obj(pic_path, pic, settings.PICS_CONTAINER, long_term_storage=False) + internal_url, external_url = save_file_obj(pic_path, pic, settings.PICS_CONTAINER, user.syndicate.name, long_term_storage=False) img_url_updated = self.detect_if_needed(printer, pic, pic_id, internal_url) if not img_url_updated: @@ -142,14 +151,14 @@ def detect_if_needed(self, printer, pic, pic_id, raw_pic_url): tagged_img.seek(0) pic_path = f'tagged/{printer.id}/{printer.current_print.id}/{pic_id}.jpg' - _, external_url = save_file_obj(pic_path, tagged_img, settings.PICS_CONTAINER, long_term_storage=False) + _, external_url = save_file_obj(pic_path, tagged_img, settings.PICS_CONTAINER, printer.user.syndicate.name, long_term_storage=False) cache.printer_pic_set(printer.id, {'img_url': external_url}, ex=IMG_URL_TTL_SECONDS) prediction_json = serializers.serialize("json", [prediction, ]) p_out = io.BytesIO() p_out.write(prediction_json.encode('UTF-8')) p_out.seek(0) - save_file_obj(f'p/{printer.id}/{printer.current_print.id}/{pic_id}.json', p_out, settings.PICS_CONTAINER, long_term_storage=False) + save_file_obj(f'p/{printer.id}/{printer.current_print.id}/{pic_id}.json', p_out, settings.PICS_CONTAINER, printer.user.syndicate.name, long_term_storage=False) if is_failing(prediction, printer.detective_sensitivity, escalating_factor=settings.ESCALATING_FACTOR): # The prediction is high enough to match the "escalated" level and hence print needs to be paused @@ -270,7 +279,7 @@ def post(self, request, format=None): (maybe_new_one_time_passcode, verification_code) = request_one_time_passcode(one_time_passcode) otp_response = { 'one_time_passcode': maybe_new_one_time_passcode, - 'one_time_passlink': f'https://app.obico.io/otp/?one_time_passcode={maybe_new_one_time_passcode}', + 'one_time_passlink': f'https://obico.onelink.me/fxEU/3ajxjqzd?deep_link_value=https://app.obico.io/printers/wizard/link/?one_time_passcode={maybe_new_one_time_passcode}', 'verification_code': verification_code} messages = [] @@ -349,9 +358,10 @@ def post(self, request): rotated_jpg_url = save_pic( f'snapshots/{printer.id}/{str(timezone.now().timestamp())}_rotated.jpg', pic, + request.user.syndicate.name, rotated=True, printer_settings=printer.settings, - to_long_term_storage=False + to_long_term_storage=False, ) print_event = PrinterEvent.create( diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 37ce6a650..599be2436 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -30,7 +30,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - exclude = ('password', 'last_login', 'is_superuser', 'is_staff', 'is_active', 'groups', 'user_permissions',) + exclude = ('password', 'last_login', 'is_superuser', 'is_staff', 'is_active', 'groups', 'user_permissions', 'syndicate') extra_kwargs = { 'id': {'read_only': True}, 'email': {'read_only': True}, diff --git a/backend/api/tests.py b/backend/api/tests.py index 5e228e280..c956da148 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -8,11 +8,12 @@ from app.models import Printer, Print, User from api.octoprint_views import * -from api.octoprint_messages import process_octoprint_status +from api.octoprint_messages import process_printer_status def init_data(): user = User.objects.create(email='test@tsd.com') + user.set_password('test') user.save() printer = Printer.objects.create(user=user) print = Print.objects.create( @@ -20,7 +21,8 @@ def init_data(): printer.current_print = print printer.save() client = Client() - client.force_login(user) + client.login(email='test@tsd.com', password='test') + return (user, printer, client) # https://docs.python.org/3/library/unittest.mock.html#where-to-patch for why it is patching "api.octoprint_views.send_failure_alert" not "lib.notifications.send_failure_alert" @@ -230,7 +232,7 @@ def test_error_resumed_then_warning_error_shortly_after(self, send_failure_alert one_minute_ago = timezone.now() - timedelta(minutes=1) with patch('django.utils.timezone.now', return_value=one_minute_ago): pause_if_needed(self.printer, None) - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintResumed')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintResumed')) alert_if_needed(self.printer, None) send_failure_alert.assert_called_once_with( @@ -279,7 +281,7 @@ def test_pause_resumed_in_octoprint_not_paused_again(self, pause_print): one_hour_ago = timezone.now() - timedelta(hours=1) with patch('django.utils.timezone.now', return_value=one_hour_ago): pause_if_needed(self.printer, None) - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintResumed')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintResumed')) pause_if_needed(self.printer, None) pause_print.assert_called_once() @@ -299,35 +301,35 @@ def setUp(self): Print.objects.all().delete(force_policy=HARD_DELETE) def test_neg_print_ts_is_ignored_when_no_current_print(self, celery_app): - process_octoprint_status(self.printer, status_msg(-1, '1.gcode', 'PrintStarted')) + process_printer_status(self.printer, status_msg(-1, '1.gcode', 'PrintStarted')) self.assertIsNone(self.printer.current_print) - process_octoprint_status(self.printer, status_msg(-1, '1.gcode', 'PrintFailed')) + process_printer_status(self.printer, status_msg(-1, '1.gcode', 'PrintFailed')) self.assertIsNone(self.printer.current_print) - process_octoprint_status(self.printer, status_msg(-1, '1.gcode', 'PrintCancelled')) + process_printer_status(self.printer, status_msg(-1, '1.gcode', 'PrintCancelled')) self.assertIsNone(self.printer.current_print) - process_octoprint_status(self.printer, status_msg(-1, '1.gcode', 'PrintPaused')) + process_printer_status(self.printer, status_msg(-1, '1.gcode', 'PrintPaused')) self.assertIsNone(self.printer.current_print) celery_app.send_task.assert_not_called() def test_print_is_done_normally(self, celery_app): - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) self.assertIsNotNone(self.printer.current_print) - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintDone')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintDone')) self.assertIsNone(self.printer.current_print) self.assertIsNotNone(Print.objects.first().finished_at) celery_app.send_task.assert_has_calls(EVENT_CALLS) self.assertEqual(celery_app.send_task.call_count, 1) def test_print_is_canceled_normally(self, celery_app): - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) self.assertIsNotNone(self.printer.current_print) - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintCancelled')) - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintFailed')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintCancelled')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintFailed')) self.assertIsNone(self.printer.current_print) self.assertIsNone(Print.objects.first().finished_at) self.assertIsNotNone(Print.objects.first().cancelled_at) @@ -335,11 +337,11 @@ def test_print_is_canceled_normally(self, celery_app): self.assertEqual(celery_app.send_task.call_count, 1) def test_lost_end_event(self, celery_app): - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) self.assertIsNotNone(self.printer.current_print) - process_octoprint_status(self.printer, status_msg_without_event(-1, '1.gcode')) - process_octoprint_status(self.printer, status_msg(100, '1.gcode', 'PrintPaused')) + process_printer_status(self.printer, status_msg_without_event(-1, '1.gcode')) + process_printer_status(self.printer, status_msg(100, '1.gcode', 'PrintPaused')) self.assertIsNotNone(self.printer.current_print) self.assertEqual(self.printer.current_print.ext_id, 100) self.assertIsNotNone(self.printer.current_print.started_at) @@ -347,14 +349,14 @@ def test_lost_end_event(self, celery_app): celery_app.send_task.assert_has_calls(EVENT_CALLS) self.assertEqual(celery_app.send_task.call_count, 1) - process_octoprint_status(self.printer, status_msg(100, '1.gcode', 'PrintDone')) + process_printer_status(self.printer, status_msg(100, '1.gcode', 'PrintDone')) self.assertEqual(celery_app.send_task.call_count, 2) def test_plugin_send_neg_print_ts_while_printing(self, celery_app): - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) - process_octoprint_status(self.printer, status_msg(-1, '1.gcode', 'PrintPaused')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) + process_printer_status(self.printer, status_msg(-1, '1.gcode', 'PrintPaused')) self.assertIsNotNone(self.printer.current_print) - process_octoprint_status(self.printer, status_msg_without_event(1, '1.gcode')) + process_printer_status(self.printer, status_msg_without_event(1, '1.gcode')) self.assertIsNotNone(self.printer.current_print) self.assertEqual(Print.objects.all_with_deleted().count(), 1) self.assertEqual(celery_app.send_task.call_count, 0) @@ -362,23 +364,23 @@ def test_plugin_send_neg_print_ts_while_printing(self, celery_app): def test_race_condition_at_end_of_print(self, celery_app): eleven_hour_ago = timezone.now() - timedelta(hours=11) with patch('django.utils.timezone.now', return_value=eleven_hour_ago): - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) - process_octoprint_status(self.printer, status_msg_without_event(-1, '1.gcode')) - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintFailed')) - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintCancelled')) + process_printer_status(self.printer, status_msg_without_event(-1, '1.gcode')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintFailed')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintCancelled')) self.assertIsNone(self.printer.current_print) celery_app.send_task.assert_has_calls(EVENT_CALLS) self.assertEqual(celery_app.send_task.call_count, 1) def test_plugin_send_diff_print_ts_while_printing(self, celery_app): - process_octoprint_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) - process_octoprint_status(self.printer, status_msg(50, '1.gcode', 'PrintPaused')) - process_octoprint_status(self.printer, status_msg_without_event(1, '1.gcode')) + process_printer_status(self.printer, status_msg(1, '1.gcode', 'PrintStarted')) + process_printer_status(self.printer, status_msg(50, '1.gcode', 'PrintPaused')) + process_printer_status(self.printer, status_msg_without_event(1, '1.gcode')) self.assertIsNotNone(self.printer.current_print) self.assertEqual(Print.objects.all_with_deleted().count(), 1) self.assertEqual(celery_app.send_task.call_count, 0) - process_octoprint_status(self.printer, status_msg_without_event(100, '1.gcode')) + process_printer_status(self.printer, status_msg_without_event(100, '1.gcode')) celery_app.send_task.assert_has_calls(EVENT_CALLS) - self.assertEqual(celery_app.send_task.call_count, 1) + self.assertEqual(celery_app.send_task.call_count, 1) \ No newline at end of file diff --git a/backend/api/viewsets.py b/backend/api/viewsets.py index ebd889b55..6d6b06e8a 100644 --- a/backend/api/viewsets.py +++ b/backend/api/viewsets.py @@ -525,10 +525,10 @@ def create(self, request): if num_bytes > file_size_limit: return Response({'error': 'File size too large'}, status=413) - self.set_metadata(gcode_file, *gcode_metadata.parse(request.FILES['file'], num_bytes, request.encoding or settings.DEFAULT_CHARSET)) + self.set_metadata(gcode_file, *gcode_metadata.parse(request.FILES['file'], num_bytes, request.encoding or settings.DEFAULT_CHARSET), request.user.syndicate.name) request.FILES['file'].seek(0) - _, ext_url = save_file_obj(self.path_in_storage(gcode_file), request.FILES['file'], settings.GCODE_CONTAINER) + _, ext_url = save_file_obj(self.path_in_storage(gcode_file), request.FILES['file'], settings.GCODE_CONTAINER, request.user.syndicate.name) gcode_file.url = ext_url gcode_file.num_bytes = num_bytes gcode_file.save() @@ -546,7 +546,7 @@ def destroy(self, request, *args, **kwargs): gcode_file.delete() return Response(status=status.HTTP_204_NO_CONTENT) - def set_metadata(self, gcode_file, metadata, thumbnails): + def set_metadata(self, gcode_file, metadata, thumbnails, syndicate_name): gcode_file.metadata_json = json.dumps(metadata) for key in ['estimated_time', 'filament_total']: setattr(gcode_file, key, metadata.get(key)) @@ -556,7 +556,7 @@ def set_metadata(self, gcode_file, metadata, thumbnails): thumb_num += 1 if thumb_num > 3: continue - _, ext_url = save_file_obj(f'gcode_thumbnails/{gcode_file.user.id}/{gcode_file.id}/{thumb_num}.png', thumb, settings.TIMELAPSE_CONTAINER) + _, ext_url = save_file_obj(f'gcode_thumbnails/{gcode_file.user.id}/{gcode_file.id}/{thumb_num}.png', thumb, settings.TIMELAPSE_CONTAINER, syndicate_name) setattr(gcode_file, f'thumbnail{thumb_num}_url', ext_url) def path_in_storage(self, gcode_file): diff --git a/backend/app/accounts.py b/backend/app/accounts.py index c53a26aec..f72ce077f 100644 --- a/backend/app/accounts.py +++ b/backend/app/accounts.py @@ -1,6 +1,67 @@ from allauth.account.models import EmailAddress from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.account.adapter import DefaultAccountAdapter +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import get_user_model +from django.core.exceptions import PermissionDenied +from app.models import User +from django.contrib.auth.hashers import make_password +import secrets +from lib.syndicate import syndicate_from_request, settings_for_syndicate + +from django.utils.encoding import force_str +from django.conf import settings + + +class SyndicateSpecificBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None: + username = kwargs.get('email') + UserModel = get_user_model() + if request is not None: + syndicate = syndicate_from_request(request) + try: + user = UserModel.objects.get(email__iexact=username, syndicate=syndicate) + if user.check_password(password): + return user + except UserModel.DoesNotExist: + return None + return None + + +class SyndicateSpecificAccountAdapter(DefaultAccountAdapter): + def save_user(self, request, user, form, commit=True): + user.syndicate = syndicate_from_request(request) + return super().save_user(request, user, form, commit) + + def populate_username(self, request, user): + user.username = f'{user.email}_{user.syndicate.id}' + + def get_from_email(self): + syndicate = syndicate_from_request(self.request) + from_email = settings_for_syndicate(syndicate.name).get('from_email', settings.DEFAULT_FROM_EMAIL) + return from_email + + def format_email_subject(self, subject): + return force_str(subject) + + def send_confirmation_mail(self, request, emailconfirmation, signup): + activate_url = self.get_email_confirmation_url(request, emailconfirmation) + syndicate = syndicate_from_request(request) + syndicate_name = settings_for_syndicate(syndicate.name).get('display_name', "Obico") + + ctx = { + "user": emailconfirmation.email_address.user, + "activate_url": activate_url, + "key": emailconfirmation.key, + "syndicate_name": syndicate_name, + } + if signup: + email_template = "account/email/email_confirmation_signup" + else: + email_template = "account/email/email_confirmation" + self.send_mail(email_template, emailconfirmation.email_address.email, ctx) class SocialAccountAdapter(DefaultSocialAccountAdapter): def pre_social_login(self, request, sociallogin): @@ -8,35 +69,30 @@ def pre_social_login(self, request, sociallogin): if sociallogin.is_existing: return + syndicate_id = syndicate_from_request(request).id # some social logins don't have an email address, e.g. facebook accounts # with mobile numbers only, but allauth takes care of this case so just # ignore it email = sociallogin.account.extra_data.get('email', '').strip().lower() if not email: - return + raise PermissionDenied('Email not exist in social login data.') - # verify we have a verified email address - # https://github.com/pennersr/django-allauth/issues/418 - # google provider might not have working sociallogin.email_addresses (buggy allauth version?) - # so we are checking extra_data first - email_verified: bool = sociallogin.account.extra_data.get('email_verified', False) - if email_verified is not True: - for _email in sociallogin.email_addresses: - if _email.email.lower() == email and _email.verified: - email_verified = True - break - - if not email_verified: - return - - # check if given email address already exists. - # Note: __iexact is used to ignore cases - try: - email_address = EmailAddress.objects.get(email__iexact=email) # FIXME verified=True? - # if it does not, let allauth take care of this new social account - except EmailAddress.DoesNotExist: - return + user = User.objects.filter(emailaddress__email__iexact=email, syndicate_id=syndicate_id).first() + if not user: + user = User.objects.get_or_create( + email=email, + syndicate_id=syndicate_id, + defaults={ + 'password': make_password(secrets.token_hex(16)), + 'username': f'{email}_{syndicate_id}', + 'is_active': True, + })[0] + EmailAddress.objects.get_or_create( + user=user, + email=email, + defaults={ + 'primary': True, + } + ) - # if it does, connect this new social login to the existing user - user = email_address.user sociallogin.connect(request, user) diff --git a/backend/app/admin.py b/backend/app/admin.py index e1cd3e185..5267c6046 100644 --- a/backend/app/admin.py +++ b/backend/app/admin.py @@ -36,20 +36,27 @@ class UserAdmin(DjangoUserAdmin): """Define admin model for custom User model with no email field.""" fieldsets = ( - (None, {'fields': ('email', 'password')}), + (None, {'fields': ('email', 'password', 'syndicate')}), (_('Personal info'), {'fields': ('first_name', 'last_name')}), (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) + add_fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('email', 'password1', 'password2'), + 'fields': ('email', 'password1', 'password2', 'syndicate'), }), ) + ordering = ('email',) actions = [send_test_email] + + def get_readonly_fields(self, request, obj=None): + if obj: + return self.readonly_fields + ('syndicate',) + return self.readonly_fields class PrinterAdmin(admin.ModelAdmin): diff --git a/backend/app/apps.py b/backend/app/apps.py deleted file mode 100644 index 74bd987b7..000000000 --- a/backend/app/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from channels_presence.apps import RoomsConfig -from django.apps import AppConfig - - -class WebAppConfig(AppConfig): - name = 'app' diff --git a/backend/app/context_processors.py b/backend/app/context_processors.py index f3abfbdf3..88797dfea 100644 --- a/backend/app/context_processors.py +++ b/backend/app/context_processors.py @@ -1,9 +1,11 @@ import re import logging from django.utils import translation - from django.conf import settings +from lib.syndicate import syndicate_from_request, settings_for_syndicate + + RE_TSD_APP_PLATFORM = re.compile(r'TSDApp-(?P\w+)') def additional_context_export(request): @@ -13,20 +15,16 @@ def additional_context_export(request): m = RE_TSD_APP_PLATFORM.match(request.headers.get('user-agent', '')) platform = m.groupdict()['platform'] if m else '' - syndicate = settings.SYNDICATE - - # per-request syndicate overrides the global setting. This is so that Mintion users can use Obico cloud but see their own theme. - syndicate_header = request.META.get('HTTP_X_OBICO_SYNDICATE', None) - if syndicate_header: - syndicate = syndicate_header - - brand_name = "Obico" if syndicate == "base" else syndicate.capitalize() + syndicate_name = syndicate_from_request(request).name + syndicate_settings = settings_for_syndicate(syndicate_name) + syndicate_settings['name'] = syndicate_name language = translation.get_language_from_request(request).split('-')[0] # ISO 639-1 standard is language_code-country_code + return { 'page_context': { 'app_platform': platform, - 'syndicate': {"provider": syndicate, "brand_name": brand_name}, + 'syndicate': syndicate_settings, 'language': language, } } diff --git a/backend/app/forms.py b/backend/app/forms.py index 3cda09f24..54de0c353 100644 --- a/backend/app/forms.py +++ b/backend/app/forms.py @@ -11,6 +11,8 @@ from .widgets import CustomRadioSelectWidget from .models import * +from django.utils.translation import gettext_lazy as _ + LOGGER = logging.getLogger(__name__) @@ -31,6 +33,9 @@ def clean(self): if has_social_accounts: self.no_password_yet = True raise err + error_messages = { + 'email_password_mismatch': _("Invalid email or password."), + } class RecaptchaSignupForm(SignupForm): diff --git a/backend/app/locale/en/LC_MESSAGES/django.po b/backend/app/locale/en/LC_MESSAGES/django.po index c438e1716..81fc12d1b 100644 --- a/backend/app/locale/en/LC_MESSAGES/django.po +++ b/backend/app/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-01 19:16+0800\n" +"POT-Creation-Date: 2024-05-10 10:18+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -30,7 +30,11 @@ msgstr "" msgid "Important dates" msgstr "" -#: app/models.py:82 +#: app/forms.py:37 +msgid "Invalid email or password." +msgstr "" + +#: app/models.py:100 msgid "email address" msgstr "" @@ -167,60 +171,72 @@ msgid "" "href=\"%(email_url)s\">issue a new e-mail confirmation request." msgstr "" -#: app/templates/account/login.html:7 app/templates/account/login.html:15 -#: app/templates/account/login.html:61 app/templates/account/signup.html:87 +#: app/templates/account/login.html:7 app/templates/account/login.html:18 +#: app/templates/account/login.html:64 app/templates/account/signup.html:92 #: app/templates/mobile/account/login.html:10 -#: app/templates/mobile/account/login.html:18 -#: app/templates/mobile/account/login.html:69 -#: app/templates/mobile/account/signup.html:91 +#: app/templates/mobile/account/login.html:21 +#: app/templates/mobile/account/login.html:72 +#: app/templates/mobile/account/signup.html:99 #: app/templates/non_vue_layout.html:49 msgid "SIGN IN" msgstr "" -#: app/templates/account/login.html:20 -#: app/templates/mobile/account/login.html:22 +#: app/templates/account/login.html:11 app/templates/account/signup.html:12 +#: app/templates/mobile/account/login.html:14 +#: app/templates/mobile/account/signup.html:16 +msgid "Email address" +msgstr "" + +#: app/templates/account/login.html:12 app/templates/account/signup.html:13 +#: app/templates/mobile/account/login.html:15 +#: app/templates/mobile/account/signup.html:17 +msgid "Password" +msgstr "" + +#: app/templates/account/login.html:23 +#: app/templates/mobile/account/login.html:25 msgid "Sign In with Facebook" msgstr "" -#: app/templates/account/login.html:22 -#: app/templates/mobile/account/login.html:24 +#: app/templates/account/login.html:25 +#: app/templates/mobile/account/login.html:27 msgid "Sign In with Google" msgstr "" -#: app/templates/account/login.html:36 -#: app/templates/mobile/account/login.html:44 +#: app/templates/account/login.html:39 +#: app/templates/mobile/account/login.html:47 msgid "Click here to reset your password" msgstr "" -#: app/templates/account/login.html:39 app/templates/account/login.html:65 -#: app/templates/mobile/account/login.html:47 -#: app/templates/mobile/account/login.html:73 +#: app/templates/account/login.html:42 app/templates/account/login.html:68 +#: app/templates/mobile/account/login.html:50 +#: app/templates/mobile/account/login.html:76 msgid "Forgot Password?" msgstr "" -#: app/templates/account/login.html:54 -#: app/templates/mobile/account/login.html:62 +#: app/templates/account/login.html:57 +#: app/templates/mobile/account/login.html:65 msgid "Remember me" msgstr "" -#: app/templates/account/login.html:68 -#: app/templates/mobile/account/login.html:76 +#: app/templates/account/login.html:71 +#: app/templates/mobile/account/login.html:79 msgid "Having Trouble?" msgstr "" -#: app/templates/account/login.html:75 -#: app/templates/mobile/account/login.html:83 +#: app/templates/account/login.html:78 +#: app/templates/mobile/account/login.html:86 msgid "Having trouble?" msgstr "" -#: app/templates/account/login.html:82 +#: app/templates/account/login.html:85 msgid "" "Your sign-in credential is the same for the mobile app and the web app. If " "you previously signed up using the mobile app, you can use the same to sign " "in here." msgstr "" -#: app/templates/account/login.html:83 +#: app/templates/account/login.html:86 msgid "" "\"Sign in with Apple\" is not available in the web app. If you want to use " "the web app, please sign up for a different account andre-link your printer\" " msgstr "" -#: app/templates/account/login.html:84 -#: app/templates/mobile/account/login.html:91 +#: app/templates/account/login.html:87 +#: app/templates/mobile/account/login.html:94 msgid "" "Even if you previously signed up an account using Google or Facebook, you " "can still" msgstr "" -#: app/templates/account/login.html:84 -#: app/templates/mobile/account/login.html:91 +#: app/templates/account/login.html:87 +#: app/templates/mobile/account/login.html:94 msgid "reset password" msgstr "" -#: app/templates/account/login.html:84 -#: app/templates/mobile/account/login.html:91 +#: app/templates/account/login.html:87 +#: app/templates/mobile/account/login.html:94 msgid "using the same email address." msgstr "" -#: app/templates/account/login.html:85 -#: app/templates/mobile/account/login.html:92 +#: app/templates/account/login.html:88 +#: app/templates/mobile/account/login.html:95 msgid "" "If you can't sign in the mobile app, re-install the mobile app and try it " "again." msgstr "" -#: app/templates/account/login.html:86 -#: app/templates/mobile/account/login.html:93 +#: app/templates/account/login.html:89 +#: app/templates/mobile/account/login.html:96 msgid "Contact us" msgstr "" -#: app/templates/account/login.html:86 -#: app/templates/mobile/account/login.html:93 +#: app/templates/account/login.html:89 +#: app/templates/mobile/account/login.html:96 msgid "if you have tried everything else." msgstr "" -#: app/templates/account/login.html:90 -#: app/templates/mobile/account/login.html:97 +#: app/templates/account/login.html:93 +#: app/templates/mobile/account/login.html:100 msgid "Close" msgstr "" -#: app/templates/account/login.html:101 app/templates/account/signup.html:85 +#: app/templates/account/login.html:104 app/templates/account/signup.html:90 +#: app/templates/mobile/account/signup.html:98 msgid "OR" msgstr "" -#: app/templates/account/login.html:102 app/templates/account/signup.html:16 -#: app/templates/account/signup.html:83 -#: app/templates/mobile/account/login.html:108 +#: app/templates/account/login.html:105 app/templates/account/signup.html:20 +#: app/templates/account/signup.html:88 +#: app/templates/mobile/account/login.html:111 #: app/templates/mobile/account/signup.html:11 -#: app/templates/mobile/account/signup.html:19 -#: app/templates/mobile/account/signup.html:88 +#: app/templates/mobile/account/signup.html:24 +#: app/templates/mobile/account/signup.html:96 #: app/templates/socialaccount/signup.html:9 #: app/templates/socialaccount/signup.html:20 msgid "SIGN UP" @@ -379,7 +396,13 @@ msgstr "" msgid "Signup" msgstr "" -#: app/templates/account/signup.html:21 +#: app/templates/account/signup.html:14 +#: app/templates/mobile/account/signup.html:18 +msgid "Password. Again" +msgstr "" + +#: app/templates/account/signup.html:26 +#: app/templates/mobile/account/signup.html:30 #, python-format msgid "" "By signing up, I agree to\n" @@ -390,23 +413,23 @@ msgid "" " Policy" msgstr "" -#: app/templates/account/signup.html:31 -#: app/templates/mobile/account/signup.html:34 +#: app/templates/account/signup.html:36 +#: app/templates/mobile/account/signup.html:42 msgid "Sign up with Facebook" msgstr "" -#: app/templates/account/signup.html:33 -#: app/templates/mobile/account/signup.html:36 +#: app/templates/account/signup.html:38 +#: app/templates/mobile/account/signup.html:44 msgid "Sign up with Google" msgstr "" -#: app/templates/account/signup.html:54 -#: app/templates/mobile/account/signup.html:59 +#: app/templates/account/signup.html:59 +#: app/templates/mobile/account/signup.html:67 msgid "We'll never share your email with anyone else." msgstr "" -#: app/templates/account/signup.html:66 -#: app/templates/mobile/account/signup.html:71 +#: app/templates/account/signup.html:71 +#: app/templates/mobile/account/signup.html:79 msgid "At least 6 characters. And be secure, please." msgstr "" @@ -467,47 +490,30 @@ msgid "" "mail address." msgstr "" -#: app/templates/email/test_email.html:5 -msgid "" -"It worked! You have successfully sent email from your self-hosted Obico " -"Server." -msgstr "" - -#: app/templates/layout.html:89 +#: app/templates/layout.html:87 msgid "All Rights Reserved" msgstr "" -#: app/templates/mobile/account/login.html:29 -#: app/templates/mobile/account/signup.html:41 +#: app/templates/mobile/account/login.html:32 +#: app/templates/mobile/account/signup.html:49 msgid "Sign in with Apple" msgstr "" -#: app/templates/mobile/account/login.html:42 +#: app/templates/mobile/account/login.html:45 msgid "This email was previously signed up using either Google or Facebook." msgstr "" -#: app/templates/mobile/account/login.html:46 +#: app/templates/mobile/account/login.html:49 msgid "Wrong email or password." msgstr "" -#: app/templates/mobile/account/login.html:90 +#: app/templates/mobile/account/login.html:93 msgid "" "Your sign-in credential is the same for the mobile app and the web app. If " "you previously signed up using the web app, you can use the same to sign in " "here." msgstr "" -#: app/templates/mobile/account/signup.html:24 -#, python-format -msgid "" -"By signing up, I agree to\n" -" the %(brand_name)s app's Terms of\n" -" Use and Privacy\n" -" Policy" -msgstr "" - #: app/templates/new_octoprinttunnel_succeeded.html:7 msgid "Succeeded" msgstr "" @@ -572,14 +578,11 @@ msgstr "" #: app/templates/printer_acted.html:31 #, python-format msgid "" -"\n" -" You are trying to %(action)s a print but %(printer_name)s is not " -"printing... maybe you clicked a link in an\n" -" outdated email?\n" -" " +" You are trying to %(action)s a print but %(printer_name)s is not " +"printing... maybe you clicked a link in an outdated email? " msgstr "" -#: app/templates/printer_acted.html:42 +#: app/templates/printer_acted.html:39 msgid "Go to webcam view" msgstr "" @@ -676,3 +679,7 @@ msgstr "" #: app/templates/unsubscribe_email.html:26 msgid "Change notification preferences" msgstr "" + +#: app/views/web_views.py:60 +msgid "A user is already registered with this email address." +msgstr "" diff --git a/backend/app/locale/zh_CN/LC_MESSAGES/django.mo b/backend/app/locale/zh_CN/LC_MESSAGES/django.mo index a8d77f811..bd4a0f472 100644 Binary files a/backend/app/locale/zh_CN/LC_MESSAGES/django.mo and b/backend/app/locale/zh_CN/LC_MESSAGES/django.mo differ diff --git a/backend/app/locale/zh_CN/LC_MESSAGES/django.po b/backend/app/locale/zh_CN/LC_MESSAGES/django.po index 594cc4fc2..5e5e2969d 100644 --- a/backend/app/locale/zh_CN/LC_MESSAGES/django.po +++ b/backend/app/locale/zh_CN/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-08 16:58+0800\n" +"POT-Creation-Date: 2024-05-10 11:13+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -29,7 +29,13 @@ msgstr "权限" msgid "Important dates" msgstr "重要日期" -#: app/models.py:82 +#: app/forms.py:37 +#, fuzzy +#| msgid "Wrong email or password." +msgid "Invalid email or password." +msgstr "错误的电子邮件或密码。" + +#: app/models.py:100 msgid "email address" msgstr "电子邮箱地址" @@ -183,53 +189,74 @@ msgstr "" "此电子邮件确认链接已过期或无效。请发起一个新的电子" "邮件确认请求。" -#: app/templates/account/login.html:7 app/templates/account/login.html:15 -#: app/templates/account/login.html:61 app/templates/account/signup.html:88 +#: app/templates/account/login.html:7 app/templates/account/login.html:18 +#: app/templates/account/login.html:64 app/templates/account/signup.html:92 #: app/templates/mobile/account/login.html:10 -#: app/templates/mobile/account/login.html:18 -#: app/templates/mobile/account/login.html:69 -#: app/templates/mobile/account/signup.html:94 +#: app/templates/mobile/account/login.html:21 +#: app/templates/mobile/account/login.html:72 +#: app/templates/mobile/account/signup.html:99 #: app/templates/non_vue_layout.html:49 msgid "SIGN IN" msgstr "登录" -#: app/templates/account/login.html:20 -#: app/templates/mobile/account/login.html:22 +#: app/templates/account/login.html:11 app/templates/account/signup.html:12 +#: app/templates/mobile/account/login.html:14 +#: app/templates/mobile/account/signup.html:16 +#, fuzzy +#| msgid "email address" +msgid "Email address" +msgstr "电子邮箱地址" + +#: app/templates/account/login.html:12 app/templates/account/signup.html:13 +#: app/templates/mobile/account/login.html:15 +#: app/templates/mobile/account/signup.html:17 +#, fuzzy +#| msgid "Set Password" +msgid "Password" +msgstr "设置密码" + +#: app/templates/account/login.html:23 +#: app/templates/mobile/account/login.html:25 msgid "Sign In with Facebook" msgstr "使用 Facebook 登录" -#: app/templates/account/login.html:22 -#: app/templates/mobile/account/login.html:24 +#: app/templates/account/login.html:25 +#: app/templates/mobile/account/login.html:27 msgid "Sign In with Google" msgstr "使用 Google 登录" -#: app/templates/account/login.html:36 -#: app/templates/mobile/account/login.html:44 +#: app/templates/account/login.html:39 +#: app/templates/mobile/account/login.html:47 msgid "Click here to reset your password" msgstr "点击这里重置您的密码" -#: app/templates/account/login.html:39 app/templates/account/login.html:65 -#: app/templates/mobile/account/login.html:47 -#: app/templates/mobile/account/login.html:73 +#: app/templates/account/login.html:41 +#: app/templates/mobile/account/login.html:49 +msgid "Wrong email or password." +msgstr "错误的电子邮件或密码。" + +#: app/templates/account/login.html:42 app/templates/account/login.html:68 +#: app/templates/mobile/account/login.html:50 +#: app/templates/mobile/account/login.html:76 msgid "Forgot Password?" msgstr "忘记密码?" -#: app/templates/account/login.html:54 -#: app/templates/mobile/account/login.html:62 +#: app/templates/account/login.html:57 +#: app/templates/mobile/account/login.html:65 msgid "Remember me" msgstr "记住我" -#: app/templates/account/login.html:68 -#: app/templates/mobile/account/login.html:76 +#: app/templates/account/login.html:71 +#: app/templates/mobile/account/login.html:79 msgid "Having Trouble?" msgstr "遇到问题?" -#: app/templates/account/login.html:75 -#: app/templates/mobile/account/login.html:83 +#: app/templates/account/login.html:78 +#: app/templates/mobile/account/login.html:86 msgid "Having trouble?" msgstr "遇到问题?" -#: app/templates/account/login.html:82 +#: app/templates/account/login.html:85 msgid "" "Your sign-in credential is the same for the mobile app and the web app. If " "you previously signed up using the mobile app, you can use the same to sign " @@ -238,7 +265,7 @@ msgstr "" "您的登录凭证在移动应用和网页应用中是相同的。如果您之前使用移动应用注册,您可" "以使用相同的凭证在这里登录。" -#: app/templates/account/login.html:83 +#: app/templates/account/login.html:86 msgid "" "\"Sign in with Apple\" is not available in the web app. If you want to use " "the web app, please sign up for a different account and重新链接您的打印机" -#: app/templates/account/login.html:84 -#: app/templates/mobile/account/login.html:91 +#: app/templates/account/login.html:87 +#: app/templates/mobile/account/login.html:94 msgid "" "Even if you previously signed up an account using Google or Facebook, you " "can still" msgstr "即使您之前使用 Google 或 Facebook 注册了账户,您仍然可以" -#: app/templates/account/login.html:84 -#: app/templates/mobile/account/login.html:91 +#: app/templates/account/login.html:87 +#: app/templates/mobile/account/login.html:94 msgid "reset password" msgstr "重置密码" -#: app/templates/account/login.html:84 -#: app/templates/mobile/account/login.html:91 +#: app/templates/account/login.html:87 +#: app/templates/mobile/account/login.html:94 msgid "using the same email address." msgstr "使用相同的电子邮件地址。" -#: app/templates/account/login.html:85 -#: app/templates/mobile/account/login.html:92 +#: app/templates/account/login.html:88 +#: app/templates/mobile/account/login.html:95 msgid "" "If you can't sign in the mobile app, re-install the mobile app and try it " "again." msgstr "如果您无法在移动应用中登录,请重新安装移动应用并再次尝试。" -#: app/templates/account/login.html:86 -#: app/templates/mobile/account/login.html:93 +#: app/templates/account/login.html:89 +#: app/templates/mobile/account/login.html:96 msgid "Contact us" msgstr "联系我们" -#: app/templates/account/login.html:86 -#: app/templates/mobile/account/login.html:93 +#: app/templates/account/login.html:89 +#: app/templates/mobile/account/login.html:96 msgid "if you have tried everything else." msgstr "如果您已尝试了其他所有方法。" -#: app/templates/account/login.html:90 -#: app/templates/mobile/account/login.html:97 +#: app/templates/account/login.html:93 +#: app/templates/mobile/account/login.html:100 msgid "Close" msgstr "关闭" -#: app/templates/account/login.html:101 app/templates/account/signup.html:86 -#: app/templates/mobile/account/signup.html:93 +#: app/templates/account/login.html:104 app/templates/account/signup.html:90 +#: app/templates/mobile/account/signup.html:98 msgid "OR" msgstr "或" -#: app/templates/account/login.html:102 app/templates/account/signup.html:16 -#: app/templates/account/signup.html:84 -#: app/templates/mobile/account/login.html:108 +#: app/templates/account/login.html:105 app/templates/account/signup.html:20 +#: app/templates/account/signup.html:88 +#: app/templates/mobile/account/login.html:111 #: app/templates/mobile/account/signup.html:11 -#: app/templates/mobile/account/signup.html:19 -#: app/templates/mobile/account/signup.html:91 +#: app/templates/mobile/account/signup.html:24 +#: app/templates/mobile/account/signup.html:96 #: app/templates/socialaccount/signup.html:9 #: app/templates/socialaccount/signup.html:20 msgid "SIGN UP" @@ -403,8 +430,15 @@ msgstr "设置密码" msgid "Signup" msgstr "注册" -#: app/templates/account/signup.html:22 -#: app/templates/mobile/account/signup.html:25 +#: app/templates/account/signup.html:14 +#: app/templates/mobile/account/signup.html:18 +#, fuzzy +#| msgid "Password Reset" +msgid "Password. Again" +msgstr "密码重置" + +#: app/templates/account/signup.html:26 +#: app/templates/mobile/account/signup.html:30 #, python-format msgid "" "By signing up, I agree to\n" @@ -418,23 +452,23 @@ msgstr "" "obico.io/terms.html\">使用条款和隐私政策" -#: app/templates/account/signup.html:32 -#: app/templates/mobile/account/signup.html:37 +#: app/templates/account/signup.html:36 +#: app/templates/mobile/account/signup.html:42 msgid "Sign up with Facebook" msgstr "使用 Facebook 注册" -#: app/templates/account/signup.html:34 -#: app/templates/mobile/account/signup.html:39 +#: app/templates/account/signup.html:38 +#: app/templates/mobile/account/signup.html:44 msgid "Sign up with Google" msgstr "使用 Google 注册" -#: app/templates/account/signup.html:55 -#: app/templates/mobile/account/signup.html:62 +#: app/templates/account/signup.html:59 +#: app/templates/mobile/account/signup.html:67 msgid "We'll never share your email with anyone else." msgstr "我们绝不会与他人分享您的电子邮件。" -#: app/templates/account/signup.html:67 -#: app/templates/mobile/account/signup.html:74 +#: app/templates/account/signup.html:71 +#: app/templates/mobile/account/signup.html:79 msgid "At least 6 characters. And be secure, please." msgstr "至少 6 个字符。请确保安全。" @@ -507,20 +541,16 @@ msgstr "" msgid "All Rights Reserved" msgstr "版权所有" -#: app/templates/mobile/account/login.html:29 -#: app/templates/mobile/account/signup.html:44 +#: app/templates/mobile/account/login.html:32 +#: app/templates/mobile/account/signup.html:49 msgid "Sign in with Apple" msgstr "使用 Apple 登录" -#: app/templates/mobile/account/login.html:42 +#: app/templates/mobile/account/login.html:45 msgid "This email was previously signed up using either Google or Facebook." msgstr "此电子邮件之前是使用 Google 或 Facebook 注册的。" -#: app/templates/mobile/account/login.html:46 -msgid "Wrong email or password." -msgstr "错误的电子邮件或密码。" - -#: app/templates/mobile/account/login.html:90 +#: app/templates/mobile/account/login.html:93 msgid "" "Your sign-in credential is the same for the mobile app and the web app. If " "you previously signed up using the web app, you can use the same to sign in " @@ -702,6 +732,19 @@ msgstr "在账户通知上。" msgid "Change notification preferences" msgstr "更改通知偏好设置" +#: app/views/web_views.py:60 +msgid "A user is already registered with this email address." +msgstr "用户已使用此电子邮件地址注册。" + +#, fuzzy +#~| msgid "Password Reset" +#~ msgid "passwordagain" +#~ msgstr "密码重置" + +#~| msgid "email address" +#~ msgid "Email Address" +#~ msgstr "电子邮箱地址" + #, python-format #~ msgid "" #~ "\n" diff --git a/backend/app/middleware.py b/backend/app/middleware.py index f3bf76221..69f663367 100644 --- a/backend/app/middleware.py +++ b/backend/app/middleware.py @@ -1,15 +1,15 @@ from django.conf import settings -from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import SuspiciousOperation, MiddlewareNotUsed, PermissionDenied from django.contrib.sessions.backends.base import UpdateError - from whitenoise.middleware import WhiteNoiseMiddleware import time from django.utils.cache import patch_vary_headers from django.contrib.sessions.middleware import SessionMiddleware from django.utils.http import http_date from django.urls import reverse, NoReverseMatch - +from django.contrib.sites.middleware import CurrentSiteMiddleware +from django.contrib.sites.models import Site +from django.http.request import split_domain_port from ipware import get_client_ip from .views import tunnelv2_views @@ -113,17 +113,6 @@ def middleware(request): return middleware -def authenticate_credentials(key): - try: - access_token = AccessToken.objects.get(token=key) - if access_token.is_valid(): - user = access_token.user - return user - else: - return None # Token is expired or invalid - except AccessToken.DoesNotExist: - return None # Token does not exist - # HTTP_X_API_KEY seems to be needed by OrcaSlicer: https://github.com/TheSpaghettiDetective/OrcaSlicer/blob/5a0f98e3f2634a61d8ad2f3b78bebf8e38f19de7/src/slic3r/GUI/PrinterWebView.cpp#L108 # But I'm not too sure if it's really needed. I'll leave it here for now. @@ -132,7 +121,14 @@ def check_x_api(get_response): def middleware(request): token = request.META.get('HTTP_X_API_KEY', '') if token: - user = authenticate_credentials(token) + user = None + try: + access_token = AccessToken.objects.get(token=key) + if access_token.is_valid(): + user = access_token.user + except AccessToken.DoesNotExist: + pass + request.user = user setattr(request.user, 'backend', 'django.contrib.auth.backends.ModelBackend') auth_login(request, request.user) @@ -140,5 +136,57 @@ def middleware(request): response = get_response(request) return response + return middleware + + +def syndicate_header(get_response): + def middleware(request): + + # HTTP_X_OBICO_SYNDICATE can only be sent for the initial request. + # The subsequent requests initiated within the page won't have it automatically set. + # Save it to a cookie so that it can be sent in subsequent requests. + syndicate_header = request.META.get('HTTP_X_OBICO_SYNDICATE', None) + if not syndicate_header: + syndicate_header = request.COOKIES.get('syndicate_header', None) + if syndicate_header: + request.META['HTTP_X_OBICO_SYNDICATE'] = syndicate_header + + response = get_response(request) + if syndicate_header: + response.set_cookie('syndicate_header', syndicate_header) + + return response + + return middleware + - return middleware \ No newline at end of file +SITE_CACHE = {} +DEFAULT_SITE = None + +class TopDomainMatchingCurrentSiteMiddleware(CurrentSiteMiddleware): + + def _get_site_by_top_level_domain(self, host): + global SITE_CACHE + # Fallback to looking up site after stripping port from the host. + domain, port = split_domain_port(host) + domain_parts = domain.split('.') + top_level_domain = '.'.join(domain_parts[-3:]) if len(domain_parts) >= 3 else domain + if top_level_domain not in SITE_CACHE: + SITE_CACHE[top_level_domain] = Site.objects.get(domain__iexact=top_level_domain) + return SITE_CACHE[top_level_domain] + + def process_request(self, request): + global DEFAULT_SITE + try: + super().process_request(request) + except Site.DoesNotExist: + host = request.get_host() + try: + site = self._get_site_by_top_level_domain(host) + except Site.DoesNotExist: + # For situations when site is not found by domain, such as load balancer health checks. + if DEFAULT_SITE is None: + DEFAULT_SITE = Site.objects.get(id=1) + site = DEFAULT_SITE + + request.site = site \ No newline at end of file diff --git a/backend/app/migrations/0077_alter_user_options_user_username_alter_user_email_and_more.py b/backend/app/migrations/0077_alter_user_options_user_username_alter_user_email_and_more.py new file mode 100644 index 000000000..ca67d6ee8 --- /dev/null +++ b/backend/app/migrations/0077_alter_user_options_user_username_alter_user_email_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 4.0.10 on 2024-05-09 01:01 + +from django.db import migrations, models +import django.db.models.deletion + +def update_username(apps, schema_editor): + User = apps.get_model('app', 'User') + for user in User.objects.all(): + user.username = f'{user.email}_1' + user.save() + +def create_syndicate(apps, schema_editor): + Syndicate = apps.get_model('app', 'Syndicate') + Site = apps.get_model('sites', 'Site') + syndicate = Syndicate.objects.create(id=1, name='base') + syndicate.sites.add(*Site.objects.all()) + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('app', '0076_remove_notificationsetting_notify_on_other_print_events'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={}, + ), + migrations.AddField( + model_name='user', + name='username', + field=models.CharField(blank=True, max_length=150, null=True), + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, verbose_name='email address'), + ), + migrations.CreateModel( + name='Syndicate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('sites', models.ManyToManyField(related_name='syndicates', to='sites.site')), + ], + ), + migrations.RunPython(create_syndicate, migrations.RunPython.noop), + migrations.AddField( + model_name='user', + name='syndicate', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='app.syndicate'), + ), + migrations.AlterUniqueTogether( + name='user', + unique_together={('email', 'syndicate')}, + ), + migrations.RunPython(update_username, migrations.RunPython.noop), + ] diff --git a/backend/app/migrations/0078_remove_email_unique_constraint.py b/backend/app/migrations/0078_remove_email_unique_constraint.py new file mode 100644 index 000000000..12606161e --- /dev/null +++ b/backend/app/migrations/0078_remove_email_unique_constraint.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.10 on 2024-05-08 00:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0077_alter_user_options_user_username_alter_user_email_and_more'), + ] + + # HACK: This migration removes the unique constraint on account_emailaddress.email. Surprisingly it worked. + operations = [ + migrations.RunSQL( + """ + DROP INDEX IF EXISTS unique_verified_email; + """, + "" + ), + ] \ No newline at end of file diff --git a/backend/app/migrations/0079_set_django_site_localhost_3334.py b/backend/app/migrations/0079_set_django_site_localhost_3334.py new file mode 100644 index 000000000..02ac4f38c --- /dev/null +++ b/backend/app/migrations/0079_set_django_site_localhost_3334.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.10 on 2024-05-10 00:52 + +from django.db import migrations, transaction +from django.db.utils import IntegrityError + +@transaction.atomic +def change_site_domain(apps, schema_editor): + Site = apps.get_model('sites', 'Site') + try: + site = Site.objects.get(domain='example.com') + site.domain = 'localhost:3334' + site.save() + except (Site.DoesNotExist, IntegrityError): + pass + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0078_remove_email_unique_constraint'), + ('sites', '0002_alter_domain_unique'), + ] + + operations = [ + migrations.RunPython(change_site_domain, migrations.RunPython.noop), + ] \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 000000000..f79699163 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,2 @@ +from .syndicate_models import * +from .other_models import * \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models/other_models.py similarity index 89% rename from backend/app/models.py rename to backend/app/models/other_models.py index 2730632f2..d2617f1be 100644 --- a/backend/app/models.py +++ b/backend/app/models/other_models.py @@ -1,5 +1,4 @@ from typing import Dict -from allauth.account.admin import EmailAddress from datetime import datetime, timedelta import logging import os @@ -7,8 +6,6 @@ from secrets import token_hex from django.db import models, IntegrityError from jsonfield import JSONField -import uuid -from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager from django.utils.translation import gettext_lazy as _ from django.db.models.signals import post_save from django.dispatch import receiver @@ -22,117 +19,15 @@ from django.contrib.auth.hashers import make_password from django.db.models import F, Q from django.db.models.constraints import UniqueConstraint - - +from django.contrib.sites.models import Site from config.celery import celery_app from lib import cache, channels from lib.utils import dict_or_none, get_rotated_pic_url +from .syndicate_models import Syndicate, User LOGGER = logging.getLogger(__name__) -UNLIMITED_DH = 100000000 # A very big number to indicate this is unlimited DH - - -def dh_is_unlimited(dh): - return dh >= UNLIMITED_DH - - -class UserManager(BaseUserManager): - """Define a model manager for User model with no username field.""" - - use_in_migrations = True - - def _create_user(self, email, password, **extra_fields): - """Create and save a User with the given email and password.""" - - if not email: - raise ValueError('The given email must be set') - - email = self.normalize_email(email) - user = self.model(email=email, **extra_fields) - user.set_password(password) - user.save(using=self._db) - - return user - - def create_user(self, email, password=None, **extra_fields): - """Create and save a regular User with the given email and password.""" - extra_fields.setdefault('is_staff', False) - extra_fields.setdefault('is_superuser', False) - - return self._create_user(email, password, **extra_fields) - - def create_superuser(self, email, password, **extra_fields): - """Create and save a SuperUser with the given email and password.""" - extra_fields.setdefault('is_staff', True) - extra_fields.setdefault('is_superuser', True) - - if extra_fields.get('is_staff') is not True: - raise ValueError('Superuser must have is_staff=True.') - - if extra_fields.get('is_superuser') is not True: - raise ValueError('Superuser must have is_superuser=True.') - - return self._create_user(email, password, **extra_fields) - - -class User(AbstractUser): - username = None - email = models.EmailField(_('email address'), unique=True) - consented_at = models.DateTimeField(null=True, blank=True) - last_active_at = models.DateTimeField(null=True, blank=True) - is_pro = models.BooleanField(null=False, blank=False, default=True) - dh_balance = models.FloatField(null=False, default=0) - unsub_token = models.UUIDField(null=False, blank=False, unique=True, db_index=True, default=uuid.uuid4, editable=False) - account_notification_by_email = models.BooleanField(null=False, blank=False, default=True) - mobile_app_canary = models.BooleanField(null=False, blank=False, default=False) - tunnel_cap_multiplier = models.FloatField(null=False, blank=False, default=1) - notification_enabled = models.BooleanField(null=False, blank=False, default=True) - unseen_printer_events = models.IntegerField(null=False, blank=False, default=0) - - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = [] - - objects = UserManager() - - def sms_eligible(self): - return self.phone_number and self.phone_country_code - - @property - def is_primary_email_verified(self): - if EmailAddress.objects.filter(user=self, email=self.email, verified=True).exists(): - return True - - return False - - @property - def is_dh_unlimited(self): - return self.dh_balance >= UNLIMITED_DH - - def tunnel_cap(self): - return -1 if self.is_pro else settings.OCTOPRINT_TUNNEL_CAP * self.tunnel_cap_multiplier - - def tunnel_usage_over_cap(self): - if self.tunnel_cap() < 0: - return False - else: - return cache.octoprinttunnel_get_stats(self.id) > self.tunnel_cap() * 1.1 # Cap x 1.1 to give some grace period to users - - -# We use a signal as opposed to a form field because users may sign up using social buttons -@receiver(post_save, sender=User) -def update_consented_at(sender, instance, created, **kwargs): - if created: - instance.consented_at = timezone.now() - instance.save() - - -@receiver(post_save, sender=User) -def init_email_notification_setting(sender, instance, created, **kwargs): - if created: - NotificationSetting.objects.get_or_create(user=instance, name='email') - class PrinterManager(SafeDeleteManager): def get_queryset(self): @@ -149,7 +44,15 @@ class Meta: (NONE, 'Just notify me'), (PAUSE, 'Pause the printer and notify me'), ) - DEFAULT_WEBCAM_SETTINGS = {'flipV': False, 'flipH': False, 'rotation': 0, 'streamRatio': '16:9'} + DEFAULT_WEBCAM_SETTINGS = { + 'name': '', + 'is_primary_camera': True, + 'is_nozzle_camera': False, + 'flipV': False, + 'flipH': False, + 'rotation': 0, + 'streamRatio': '16:9', + } name = models.CharField(max_length=256, null=False) auth_token = models.CharField(max_length=256, unique=True, null=False, blank=False) @@ -192,8 +95,18 @@ def pic(self): def settings(self): p_settings = cache.printer_settings_get(self.id) - for key in ('webcam_flipV', 'webcam_flipH', 'webcam_rotate90'): # `webcam_rotate90` for backward compatibility with old plugins - p_settings[key] = p_settings.get(key, 'False') == 'True' + webcam_settings = self.DEFAULT_WEBCAM_SETTINGS.copy() + + if p_settings.get('webcams') is not None: + p_settings['webcams'] = json.loads(p_settings.get('webcams')) + + ## Backward compatibility with mobile app 2.10 or earlier + + if len(p_settings['webcams']) > 0: + webcam_settings = p_settings['webcams'][0] + + for key in ('flipV', 'flipH', 'rotate90', 'rotation', 'streamRatio'): + p_settings['webcam_' + key] = webcam_settings.get(key) if 'webcam_rotation' in p_settings: rotation_int = int(p_settings['webcam_rotation']) @@ -204,6 +117,8 @@ def settings(self): p_settings['ratio169'] = p_settings.get('webcam_streamRatio', '16:9') == '16:9' + ## End of backward compatibility with mobile app 2.10 or earlier + if p_settings.get('temp_profiles'): p_settings['temp_profiles'] = json.loads(p_settings.get('temp_profiles')) @@ -977,3 +892,10 @@ def config(self) -> Dict: class Meta: unique_together = ('user', 'name') + + +@receiver(post_save, sender=User) +def init_email_notification_setting(sender, instance, created, **kwargs): + if created: + NotificationSetting.objects.get_or_create(user=instance, name='email') + diff --git a/backend/app/models/syndicate_models.py b/backend/app/models/syndicate_models.py new file mode 100644 index 000000000..40478d1c4 --- /dev/null +++ b/backend/app/models/syndicate_models.py @@ -0,0 +1,136 @@ +from django.db import models +from allauth.account.models import EmailAddress +from django.dispatch import receiver +from django.db.models.signals import post_save +from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager +from django.contrib.sites.models import Site +from django.utils.translation import gettext_lazy as _ +import uuid +from django.utils import timezone +from django.conf import settings + +from lib import cache + +UNLIMITED_DH = 100000000 # A very big number to indicate this is unlimited DH + +def dh_is_unlimited(dh): + return dh >= UNLIMITED_DH + + +class Syndicate(models.Model): + sites = models.ManyToManyField(Site, related_name='syndicates') + name = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f'{self.name}' + + +@receiver(post_save, sender=Site) +def add_site_to_default_syndicate(sender, instance, created, **kwargs): + if created: + try: + syndicate = Syndicate.objects.order_by('id').first() + if syndicate: + syndicate.sites.add(instance) + except Syndicate.DoesNotExist: + pass + +post_save.connect(add_site_to_default_syndicate, sender=Site) + + +class UserManager(BaseUserManager): + """Define a model manager for User model with no username field.""" + + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """Create and save a User with the given email and password.""" + + if not email: + raise ValueError('The given email must be set') + + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + + return user + + def create_user(self, email, password=None, **extra_fields): + """Create and save a regular User with the given email and password.""" + extra_fields.setdefault('is_staff', False) + extra_fields.setdefault('is_superuser', False) + + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + """Create and save a SuperUser with the given email and password.""" + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser must have is_staff=True.') + + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return self._create_user(email, password, **extra_fields) + + +class User(AbstractUser): + username = None + email = models.EmailField(_('email address')) + syndicate = models.ForeignKey(Syndicate, on_delete=models.CASCADE, default=1) + username = models.CharField(max_length=150, blank=True, null=True) + consented_at = models.DateTimeField(null=True, blank=True) + last_active_at = models.DateTimeField(null=True, blank=True) + is_pro = models.BooleanField(null=False, blank=False, default=True) + dh_balance = models.FloatField(null=False, default=0) + unsub_token = models.UUIDField(null=False, blank=False, unique=True, db_index=True, default=uuid.uuid4, editable=False) + account_notification_by_email = models.BooleanField(null=False, blank=False, default=True) + mobile_app_canary = models.BooleanField(null=False, blank=False, default=False) + tunnel_cap_multiplier = models.FloatField(null=False, blank=False, default=1) + notification_enabled = models.BooleanField(null=False, blank=False, default=True) + unseen_printer_events = models.IntegerField(null=False, blank=False, default=0) + + class Meta: + unique_together = [['email', 'syndicate']] + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] + DEFAULT_SYNDICATE = Syndicate(name='base') + + objects = UserManager() + + def sms_eligible(self): + return self.phone_number and self.phone_country_code + + @property + def is_primary_email_verified(self): + if EmailAddress.objects.filter(user=self, email=self.email, verified=True).exists(): + return True + + return False + + @property + def is_dh_unlimited(self): + return self.dh_balance >= UNLIMITED_DH + + def tunnel_cap(self): + return -1 if self.is_pro else settings.OCTOPRINT_TUNNEL_CAP * self.tunnel_cap_multiplier + + def tunnel_usage_over_cap(self): + if self.tunnel_cap() < 0: + return False + else: + return cache.octoprinttunnel_get_stats(self.id) > self.tunnel_cap() * 1.1 # Cap x 1.1 to give some grace period to users + + +# We use a signal as opposed to a form field because users may sign up using social buttons +@receiver(post_save, sender=User) +def update_consented_at(sender, instance, created, **kwargs): + if created: + instance.consented_at = timezone.now() + instance.save() \ No newline at end of file diff --git a/backend/app/tasks.py b/backend/app/tasks.py index 943220fd5..289432256 100644 --- a/backend/app/tasks.py +++ b/backend/app/tasks.py @@ -30,7 +30,7 @@ from lib.prediction import update_prediction_with_detections, is_failing, VISUALIZATION_THRESH from lib.image import overlay_detections from lib import cache -from lib import site +from lib import syndicate from notifications.handlers import handler from notifications import notification_types from api.octoprint_views import IMG_URL_TTL_SECONDS @@ -93,7 +93,7 @@ def compile_timelapse(print_id): subprocess.run(cmd.split(), check=True) with open(output_mp4, 'rb') as mp4_file: - _, mp4_file_url = save_file_obj('private/{}'.format(mp4_filename), mp4_file, settings.TIMELAPSE_CONTAINER) + _, mp4_file_url = save_file_obj('private/{}'.format(mp4_filename), mp4_file, settings.TIMELAPSE_CONTAINER, _print.printer.user.syndicate.name) _print.video_url = mp4_file_url _print.save(keep_deleted=True) @@ -109,7 +109,7 @@ def compile_timelapse(print_id): local_pics[0].parent, ffmpeg_extra_options, output_mp4) subprocess.run(cmd.split(), check=True) with open(output_mp4, 'rb') as mp4_file: - _, mp4_file_url = save_file_obj('private/{}'.format(mp4_filename), mp4_file, settings.TIMELAPSE_CONTAINER) + _, mp4_file_url = save_file_obj('private/{}'.format(mp4_filename), mp4_file, settings.TIMELAPSE_CONTAINER, _print.printer.user.syndicate.name) json_files = list_dir(f'p/{pic_dir}/', settings.PICS_CONTAINER, long_term_storage=False) local_jsons = download_files(json_files, to_dir) @@ -132,7 +132,7 @@ def compile_timelapse(print_id): prediction_json_io = io.BytesIO() prediction_json_io.write(json.dumps(prediction_json).encode('UTF-8')) prediction_json_io.seek(0) - _, json_url = save_file_obj('private/{}_p.json'.format(_print.id), prediction_json_io, settings.TIMELAPSE_CONTAINER) + _, json_url = save_file_obj('private/{}_p.json'.format(_print.id), prediction_json_io, settings.TIMELAPSE_CONTAINER, _print.printer.user.syndicate.name) _print.tagged_video_url = mp4_file_url _print.prediction_json_url = json_url @@ -154,7 +154,7 @@ def preprocess_timelapse(self, user_id, video_path, filename): _print = Print.objects.create(user_id=user_id, filename=filename, uploaded_at=timezone.now()) with open(converted_mp4_path, 'rb') as mp4_file: - _, video_url = save_file_obj(f'private/{_print.id}.mp4', mp4_file, settings.TIMELAPSE_CONTAINER) + _, video_url = save_file_obj(f'private/{_print.id}.mp4', mp4_file, settings.TIMELAPSE_CONTAINER, _print.printer.user.syndicate.name) _print.video_url = video_url _print.save(keep_deleted=True) @@ -195,7 +195,7 @@ def detect_timelapse(self, print_id): jpg_abs_path = os.path.join(jpgs_dir, jpg_path) with open(jpg_abs_path, 'rb') as pic: pic_path = f'{_print.user.id}/{_print.id}/{jpg_path}' - internal_url, _ = save_file_obj(f'uploaded/{pic_path}', pic, settings.PICS_CONTAINER, long_term_storage=False) + internal_url, _ = save_file_obj(f'uploaded/{pic_path}', pic, settings.PICS_CONTAINER, _print.printer.user.syndicate.name, long_term_storage=False) req = requests.get(settings.ML_API_HOST + '/p/', params={'img': internal_url}, headers=ml_api_auth_headers(), verify=False) req.raise_for_status() detections = req.json()['detections'] @@ -210,17 +210,17 @@ def detect_timelapse(self, print_id): overlay_detections(Image.open(jpg_abs_path), detections_to_visualize).save(os.path.join(tagged_jpgs_dir, jpg_path), "JPEG") predictions_json = serializers.serialize("json", predictions) - _, json_url = save_file_obj(f'private/{_print.id}_p.json', io.BytesIO(str.encode(predictions_json)), settings.TIMELAPSE_CONTAINER) + _, json_url = save_file_obj(f'private/{_print.id}_p.json', io.BytesIO(str.encode(predictions_json)), settings.TIMELAPSE_CONTAINER, _print.printer.user.syndicate.name) mp4_filename = f'{_print.id}_tagged.mp4' output_mp4 = os.path.join(tmp_dir, mp4_filename) subprocess.run( f'ffmpeg -y -r 30 -pattern_type glob -i {tagged_jpgs_dir}/*.jpg -c:v libx264 -pix_fmt yuv420p -vf pad=ceil(iw/2)*2:ceil(ih/2)*2 {output_mp4}'.split(), check=True) with open(output_mp4, 'rb') as mp4_file: - _, mp4_file_url = save_file_obj(f'private/{mp4_filename}', mp4_file, settings.TIMELAPSE_CONTAINER) + _, mp4_file_url = save_file_obj(f'private/{mp4_filename}', mp4_file, settings.TIMELAPSE_CONTAINER, _print.printer.user.syndicate.name) with open(os.path.join(jpgs_dir, jpg_filenames[-1]), 'rb') as poster_file: - _, poster_file_url = save_file_obj(f'private/{_print.id}_poster.jpg', poster_file, settings.TIMELAPSE_CONTAINER) + _, poster_file_url = save_file_obj(f'private/{_print.id}_poster.jpg', poster_file, settings.TIMELAPSE_CONTAINER, _print.printer.user.syndicate.name) _print.tagged_video_url = mp4_file_url _print.prediction_json_url = json_url @@ -277,6 +277,7 @@ def will_record_timelapse(_print): unrotated_jpg_url = copy_pic( last_pic, f'snapshots/{_print.printer.id}/latest_unrotated.jpg', + _print.printer.user.syndicate.name, rotated=False, to_long_term_storage=False ) @@ -300,7 +301,7 @@ def send_timelapse_detection_done_email(_print): ctx = { 'print': _print, 'unsub_url': 'https://app.obico.io/ent/email_unsubscribe/?list=notification&email={}'.format(_print.user.email), - 'prints_link': site.build_full_url('/prints/'), + 'prints_link': syndicate.build_full_url_for_syndicate('/prints/', _print.printer.user.syndicate.name), } emails = [email.email for email in EmailAddress.objects.filter(user=_print.user)] message = get_template('email/upload_print_processed.html').render(ctx) @@ -334,6 +335,7 @@ def highest_7_predictions(prediction_list): rotated_jpg_url = copy_pic( f'raw/{_print.printer.id}/{_print.id}/{ts}.jpg', f'ff_printshots/{_print.user.id}/{_print.id}/{ts}.jpg', + _print.printer.user.syndicate.name, rotated=True, printer_settings=_print.printer.settings, to_long_term_storage=False diff --git a/backend/app/templates/account/email/email_confirmation_message.html b/backend/app/templates/account/email/email_confirmation_message.html index 8bb6f0852..b374329c1 100644 --- a/backend/app/templates/account/email/email_confirmation_message.html +++ b/backend/app/templates/account/email/email_confirmation_message.html @@ -1,11 +1,15 @@ {% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %} -Thanks for choosing Obico! +Thanks for joining {{ syndicate_name }}!

-Please click this link to verify your email address. +Please click the link below to verify your email address:

-If the click doesn't work, you can copy and paste this link into the browser's address bar: + Verify My Email +

+ If the link above doesn't work, you can copy and paste the URL directly into your browser's address bar:
{{ activate_url }}

-It is also a good idea to add our email address support@obico.io to your contact list so that our emails won't end up in your spam folder. +To ensure our future communications reach you, please add our email address to your contact list. This will help prevent our emails from landing in your spam folder. +

+Welcome aboard! We look forward to enhancing your 3D printing experience with {{ syndicate_name }}! {% endautoescape %} diff --git a/backend/app/templates/account/email/email_confirmation_subject.txt b/backend/app/templates/account/email/email_confirmation_subject.txt index b0a876f5b..ceccdfd18 100644 --- a/backend/app/templates/account/email/email_confirmation_subject.txt +++ b/backend/app/templates/account/email/email_confirmation_subject.txt @@ -1,4 +1,4 @@ {% load i18n %} {% autoescape off %} -{% blocktrans %}Please Confirm Your E-mail Address{% endblocktrans %} +{% blocktrans %}Verify your email address!{% endblocktrans %} {% endautoescape %} diff --git a/backend/app/templates/account/email/password_reset_key_message.txt b/backend/app/templates/account/email/password_reset_key_message.txt index 917cd933e..e124e35e5 100644 --- a/backend/app/templates/account/email/password_reset_key_message.txt +++ b/backend/app/templates/account/email/password_reset_key_message.txt @@ -5,7 +5,5 @@ It can be safely ignored if you did not request a password reset. Click the link {{ password_reset_url }} -{% if username %}{% blocktrans %}In case you forgot, your username is {{ username }}.{% endblocktrans %} - -{% endif %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you for using {{ site_name }}! +{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you for using {{ site_name }}! {{ site_domain }}{% endblocktrans %} diff --git a/backend/app/templates/account/login.html b/backend/app/templates/account/login.html index 251a67cc2..7d2b0b2c5 100644 --- a/backend/app/templates/account/login.html +++ b/backend/app/templates/account/login.html @@ -8,6 +8,9 @@ {% block content %} {% get_providers as socialaccount_providers %} +{% trans 'Email address' as email_placeholder %} +{% trans 'Password' as password_placeholder %} +
@@ -16,10 +19,16 @@

{% trans "SIGN IN" %}

{% if socialaccount_providers %} {% endif %} @@ -35,7 +44,7 @@

{% trans "SIGN IN" %}

{% trans "Click here to reset your password" %}. {% else %} - Wrong email or password. + {% trans "Wrong email or password." %} {% trans "Forgot Password?" %} {% endif %}
@@ -43,10 +52,10 @@

{% trans "SIGN IN" %}

{% csrf_token %} {% with WIDGET_ERROR_CLASS='field_error' WIDGET_REQUIRED_CLASS='field_required' %}
- {% render_field form.login class="form-control" placeholder="Email address" %} + {% render_field form.login class="form-control" placeholder=email_placeholder %}
- {% render_field form.password class="form-control" placeholder="Password" %} + {% render_field form.password class="form-control" placeholder=password_placeholder %}
diff --git a/backend/app/templates/account/signup.html b/backend/app/templates/account/signup.html index 12436aee0..6d3da0de6 100644 --- a/backend/app/templates/account/signup.html +++ b/backend/app/templates/account/signup.html @@ -9,6 +9,10 @@ {% block content %} {% get_providers as socialaccount_providers %} +{% trans 'Email address' as email_placeholder %} +{% trans 'Password' as password_placeholder %} +{% trans 'Password. Again' as password_again_placeholder %} +
@@ -18,8 +22,8 @@

{% trans "SIGN UP" %}

{% if socialaccount_providers %} {% endif %} @@ -44,7 +54,7 @@

{% trans "SIGN UP" %}

{% with WIDGET_ERROR_CLASS='field_error' WIDGET_REQUIRED_CLASS='field_required' %}
- {% render_field form.email class="form-control" aria-describedby="emailHelp" placeholder="Email address" %} + {% render_field form.email class="form-control" aria-describedby="emailHelp" placeholder=email_placeholder %} {% if form.email.errors %} {% for error in form.email.errors %} @@ -56,7 +66,7 @@

{% trans "SIGN UP" %}

{% endif %}
- {% render_field form.password1 class="form-control" aria-describedby="password1Help" placeholder="Password" %} + {% render_field form.password1 class="form-control" aria-describedby="password1Help" placeholder=password_placeholder %} {% if form.password1.errors %} {% for error in form.password1.errors %} @@ -68,7 +78,7 @@

{% trans "SIGN UP" %}

{% endif %}
- {% render_field form.password2 class="form-control" aria-describedby="password2Help" placeholder="Password. Again" %} + {% render_field form.password2 class="form-control" aria-describedby="password2Help" placeholder=password_again_placeholder %} {% if form.password2.errors %} {% for error in form.password2.errors %} diff --git a/backend/app/templates/layout.html b/backend/app/templates/layout.html index bf2027c35..d139be253 100644 --- a/backend/app/templates/layout.html +++ b/backend/app/templates/layout.html @@ -14,10 +14,10 @@ {% block meta_viewport %} {% endblock meta_viewport %} - + - {{ page_context.syndicate.brand_name }} + {{ page_context.syndicate.display_name }} {% with favicon_path='img/favicon.png' %} - {% if syndicate and syndicate.provider != 'base' %} - {% with favicon_path=syndicate.provider|add:'/img/favicon.png' %} + {% if page_context.syndicate and page_context.syndicate.name != 'base' %} + {% with favicon_path=page_context.syndicate.name|add:'/img/favicon.png' %} {% endwith %} {% else %} @@ -59,7 +59,7 @@ {{ page_context|json_script:"page-context-json" }} - + {% csrf_token %} {% block top_page_js %}{% endblock top_page_js %} @@ -82,7 +82,7 @@ {% elif not page_context.app_platform %}
-

© {{ page_context.syndicate.brand_name }} {% now "Y" %}. {% trans "All Rights Reserved" %}.

+

© {{ page_context.syndicate.display_name }} {% now "Y" %}. {% trans "All Rights Reserved" %}.

{% endif %} diff --git a/backend/app/templates/mobile/account/login.html b/backend/app/templates/mobile/account/login.html index 4df7c4b75..5438ab622 100644 --- a/backend/app/templates/mobile/account/login.html +++ b/backend/app/templates/mobile/account/login.html @@ -11,6 +11,9 @@ {% block content %} {% get_providers as socialaccount_providers %} +{% trans 'Email address' as email_placeholder %} +{% trans 'Password' as password_placeholder %} +
@@ -18,10 +21,16 @@

{% trans "SIGN IN" %}

{% if socialaccount_providers %} {% if page_context.app_platform == 'ios' %}