davidcr01 / WordlePlus

Repository to store all the documentation, files and structure of my Final Degree Project (TFG in Spanish). The main goal is to develop, as full-stack web developer, a remodel of the Wordle game, including more features and functionalities using Ionic, Django REST Api and PostgreSQL.
1 stars 0 forks source link

Modify personal data (S4) #26

Closed davidcr01 closed 1 year ago

davidcr01 commented 1 year ago

Description

As a player, is necessary to implement a new view and logic to allow the user to edit the personal information.

Tasks

davidcr01 commented 1 year ago

Update Report

This feature will be contained in the Settings part of the application. This new code has been added to the tab4 page:

HTML:

<ion-header>
  <ion-toolbar>
    <ion-title>
      Settings
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <div class="flex-center">
  <ion-card>
    <ion-card-content>
      <ion-list>
        <ion-item class="option" (click)="editPersonalInfo()">
          <ion-label>Edit personal information</ion-label>
          <ion-button slot="end" fill="clear">
            <ion-icon class="icon" name="create"></ion-icon>
          </ion-button>
        </ion-item>

        <ion-item class="logout" (click)="logout()">
          <ion-label class="option">Logout</ion-label>
          <ion-button slot="end" fill="clear">
            <ion-icon class="icon" name="log-out"></ion-icon>
          </ion-button>
        </ion-item>
      </ion-list>
    </ion-card-content>
  </ion-card>
  </div>
</ion-content>

Just as simple as a card with the available options. Notice that the logout option is added here.

TS:

@Component({
  selector: 'app-tab4',
  templateUrl: 'tab4.page.html',
  styleUrls: ['tab4.page.scss']
})
export class Tab4Page {

  constructor(private storageService: StorageService, private router: Router) {}

  logout() {
    this.storageService.destroyAll();
    this.router.navigateByUrl('/login');
  }

  editPersonalInfo() {
    this.router.navigate(['/edit-user']);
  }
}

And the TS logic just redirects the user depending on the option. If the logout option is clicked, the Storage information is removed (and the token too) and the user is redirected to the main page.

davidcr01 commented 1 year ago

Update Report - Edit user information

Backend

To implement this feature, a new serializer and view have been added. The used model is the CustomUser model.

The serializer:

class UserInfoPartialSerializer(serializers.ModelSerializer):
    class Meta:
        model = CustomUser
        fields = ['email', 'first_name', 'last_name']

We just specify the fields that we are interested int.

The view:

class UserInfoAPIView(APIView):
    permission_classes = [permissions.IsAuthenticated]

    def get(self, request):
        user = request.user
        serializer = UserInfoPartialSerializer(user)
        return Response(serializer.data)

    def patch(self, request):
        user = request.user
        serializer = UserInfoPartialSerializer(user, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=400)

It returns or modifies the information given by the user identified by the token. Finally, we add the path to the urls.py file as api/users-info, using the defined view.

If we test the new URL, we obtain the following results: image

The PATCH request: image

A new view has been defined in order to not to modify the CustomUser serializer and view, and to only allow the GET and PATCH options.

Frontend - UI

A new page has been created: edit-user page. The UI of this page consist in a form contained in a center card:

<form [formGroup]="userInfoForm" (ngSubmit)="saveUserInfo()">
        <ion-list>
        <ion-item>
          <ion-label class="field" position="floating">Email</ion-label>
          <ion-input type="email" formControlName="email"></ion-input>
        </ion-item>
        <ion-item>
          <ion-label class="field" position="floating">First Name</ion-label>
          <ion-input type="text" formControlName="firstName"></ion-input>
        </ion-item>
        <ion-item>
          <ion-label class="field" position="floating">Last Name</ion-label>
          <ion-input type="text" formControlName="lastName"></ion-input>
        </ion-item>
        </ion-list>
        <div class="error-message" *ngIf="userInfoForm.get('email').invalid && userInfoForm.get('email').dirty">
          Please enter a valid email address.
        </div>
        <div class="error-message" *ngIf="userInfoForm.get('firstName').invalid && userInfoForm.get('firstName').dirty">
          Please enter a first name.
        </div>
        <div class="error-message" *ngIf="userInfoForm.get('lastName').invalid && userInfoForm.get('lastName').dirty">
          Please enter a last name.
        </div>
        <ion-button type="submit" expand="full" [disabled]="userInfoForm.invalid">Save</ion-button>
      </form>

Notice that we are using a ReactiveForm and div have been added to show error messages in case of the form is not valid. The submit button will be available if the form is valid.

Frontend - Controller

About the TS logic:

export class EditUserPage implements OnInit {
  userInfo: any = {};
  userInfoForm: FormGroup;

  constructor(private apiService: ApiService,
    private storageService: StorageService, 
    private router: Router,
    private toastService: ToastService,
    private formBuilder: FormBuilder) {
      this.userInfoForm = this.formBuilder.group({
        email: ['', [Validators.required, Validators.email]],
        firstName: ['', [Validators.required, Validators.maxLength(20)]],
        lastName: ['', [Validators.required, Validators.maxLength(20)]],
      });
    }

  ngOnInit() {
    this.getUserInfo();
  }

  async getUserInfo() {
    (await this.apiService.getUserInfo()).subscribe((response: any) => {
      this.userInfo = response;
      this.userInfoForm.patchValue({
        email: this.userInfo.email,
        firstName: this.userInfo.first_name,
        lastName: this.userInfo.last_name,
      });
    });
  }

  async saveUserInfo() {
    if (this.userInfoForm.valid) {
      const email = this.userInfoForm.get('email').value;
      const firstName = this.userInfoForm.get('firstName').value;
      const lastName = this.userInfoForm.get('lastName').value;
      const body = {
        'email': email,
        'first_name': firstName,
        'last_name': lastName
      };

      (await this.apiService.updateUserInfo(body)).subscribe(
        () => {
          this.toastService.showToast('Information updated succesfully!', 2000, 'top');
          this.router.navigate(['/tabs/settings']);
        },
        (error) => {
            this.toastService.showToast('An error was generated', 2000, 'top');
          console.error('Error saving the information:', error);
        }
      );
    } else {
      console.error('Formulario inválido');
    }
  }
}

The following video shows how the information is fetched and saved:

https://github.com/davidcr01/WordlePlus/assets/72193239/dfe41334-8ef7-4eda-89a6-f4c3206ff384

davidcr01 commented 1 year ago

Update Report

Modifying the avatar

A new functionality has been added to upload the avatar. In the form, this code has been added:

<ion-card>
      <ion-card-header>
        <ion-card-title>Change Avatar</ion-card-title>
      </ion-card-header>
      <ion-card-content>
        <ion-item>
          <ion-label class="field">Current Avatar</ion-label>
          <ion-avatar slot="end">
            <img [src]="avatarPreview" />
          </ion-avatar>
        </ion-item>

        <input type="file" accept="image/*" #avatarInput (change)="readAndPreviewAvatar(avatarInput.files[0])"/>
        <ion-button expand="full" (click)="uploadAvatar()">Upload New Avatar</ion-button>
      </ion-card-content>
    </ion-card>

To upload and save the avatar, these methods have been added:

async uploadAvatar() {
    const file = this.avatarInput.nativeElement.files[0];
    if (file) {
      console.log(file);
      const reader = new FileReader();
      reader.onloadend = () => {
        const avatarData = reader.result as string;
        console.log(avatarData);
        this.saveAvatar(avatarData);
      };
      reader.readAsDataURL(file);
    }
  }

  async saveAvatar(avatarData: string) {
    try {
      await (await this.apiService.saveAvatarImage(avatarData)).toPromise();
      this.storageService.setAvatarUrl(avatarData);
      this.toastService.showToast('Avatar updated successfully!', 2000, 'top');
      this.getUserInfo(); // Refresh user info to update avatar preview
      this.router.navigate(['/tabs/main'], { queryParams: { refresh: 'true' } });
    } catch (error) {
      console.error('Error uploading avatar:', error);
    }
  }

The file is selected from the input, reader by the reader variable and send to the saveAvatar function. This function post the avatar to the API and saves it in the StorageService.

The AvatarView has been modified. As the image is sent encoded in base64 and decoded in base64 when returning it, is unnecessary to implement this logic. Instead, the base64 code of the image is stored in the database.

class AvatarView(APIView):
    permission_classes = [permissions.IsAuthenticated]

    def get(self, request, user_id):
        try:
            user = get_object_or_404(CustomUser, id=user_id)
            if request.user == user:
                if user.avatar:
                    avatar_data = user.avatar.read()
                    return JsonResponse({'avatar': avatar_data.decode('utf-8')}, status=200, safe=False)
                else:
                    return Response({'detail': 'Avatar not available.'}, status=404)
            else:
                return Response({'detail': 'You do not have permission to get the avatar.'}, status=403)
        except CustomUser.DoesNotExist:
            return Response({'detail': 'The specified user does not exist.'}, status=404)

    def post(self, request, user_id):
        try:
            user = get_object_or_404(CustomUser, id=user_id)
            if request.user == user:
                avatar_data = request.data.get('avatar')
                if avatar_data:
                     # Delete the existing avatar if it exists
                    if user.avatar:
                        user.avatar.delete()

                    # Save the avatar image without encoding or decoding
                    filename = f'{user_id}_avatar.png'
                    user.avatar.save(filename, ContentFile(avatar_data.encode('utf-8')))
                    return Response({'detail': 'Avatar uploaded correctly.'}, status=200)
                else:
                    return Response({'detail': 'No avatar image attached.'}, status=400)
            else:
                return Response({'detail': 'You do not have permission to upload an avatar.'}, status=403)
        except CustomUser.DoesNotExist:
            return Response({'detail': 'The specified user does not exist.'}, status=404)   

The result is: image

https://github.com/davidcr01/WordlePlus/assets/72193239/69e4f033-820f-43ad-b329-339846712f2a